feat(code-review): add local reviewer agents#3
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a local review pipeline for CodeRabbit and Kilo Code, with docs, service logic, tool registration, a CLI entrypoint, config, locale strings, and tests. ChangesLocal Code Review Agents
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces local code review capabilities to Hivekeep, integrating CodeRabbit and Kilo Code as first-class reviewer providers. It adds CLI scripts, tools, configuration options, translation keys, and comprehensive unit tests. The review feedback highlights several robust improvements to the core local-review service: wrapping Bun.spawn in a try-catch block to prevent unhandled synchronous errors when a CLI is missing, enhancing JSON parsing to support pretty-printed JSON objects in addition to JSON lines, safely parsing stringified line numbers, and explicitly checking for execution timeouts to provide clearer error messages.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
Code Review SummaryStatus: No Issues Found | Recommendation: Merge Files Reviewed (2 files)
Previous Review Summaries (11 snapshots, latest commit 6b6d0d9)Current summary above is authoritative. Previous snapshots are kept for context only. Previous review (commit 6b6d0d9)Status: No Issues Found | Recommendation: Merge Files Reviewed (5 files)
Previous review (commit cbfaaec)Status: No Issues Found | Recommendation: Merge Files Reviewed (3 files)
Previous review (commit a45e384)Status: 4 Issues Found | Recommendation: Address before merge Overview
The incremental commits
New findings (changed-code)Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (13 files)
Fix these issues in Kilo Cloud Previous review (commit 5746e97)Status: No Issues Found | Recommendation: Merge The incremental commit What changed
Pre-existing concerns resolved by this commit
Observations (unchanged file, not in PR diff)
No other pre-existing comments target changed lines. Files Reviewed (2 files)
Previous review (commit 02557f7)Status: No Issues Found | Recommendation: Merge The incremental commit What changed
Pre-existing concerns resolved by this commit
Observations (unchanged file, not in PR diff)
No other pre-existing comments target changed lines. Files Reviewed (2 files)
Previous review (commit 4f443b6)Status: No Issues Found | Recommendation: Merge The incremental commit What changed
Pre-existing concerns resolved by this commitTwo open review comments anchored on the unchanged
No other pre-existing comments target changed lines, so per the changed-lines-only scope nothing else is carried forward. Files Reviewed (2 files)
Previous review (commit 5f44109)Status: No Issues Found | Recommendation: Merge The incremental commit
The pre-existing cursor bugbot comments ( No new CRITICAL, WARNING, or SUGGESTION issues were introduced on changed lines. The mode-tag type narrowing is preserved ( Files Reviewed (3 files)
Previous review (commit 2655a06)Status: No Issues Found | Recommendation: Merge The incremental commit
The two bugbot summaries confirm the fixes are correct:
No new CRITICAL, WARNING, or SUGGESTION issues were introduced on changed lines. Type narrowing on the narrowed Files Reviewed (2 files)
Previous review (commit 4668d95)Status: No Issues Found | Recommendation: Merge The incremental commit
No new CRITICAL, WARNING, or SUGGESTION issues were introduced on changed lines. Files Reviewed (2 files)
Previous review (commit 7501d57)Status: No Issues Found | Recommendation: Merge The two previous issues on the changed lines are now correctly fixed:
No new CRITICAL, WARNING, or SUGGESTION issues were introduced on changed lines. Files Reviewed (1 file)
Previous review (commit 070c5fa)Status: 4 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
SUGGESTION
Files Reviewed (11 fil
|
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (1)
docs/local-review-agents.md (1)
67-69: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAvoid baking a one-off Kilo CLI version into the spec.
Kilo 7.3.44 was verified locallywill drift as soon as the locally tested CLI changes, but nothing in the runtime contract shown here keeps the doc in sync. Prefer version-agnostic wording here, or move exact compatibility claims into a dated support matrix.Suggested wording
-Kilo 7.3.44 was verified locally. The CLI exposes `kilo run --format json --auto --dir <repo>` for non-interactive runs. +The Kilo CLI was verified locally during implementation. The CLI exposes `kilo run --format json --auto --dir <repo>` for non-interactive runs.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/local-review-agents.md` around lines 67 - 69, The docs text currently hard-codes a specific Kilo CLI version in the local-review-agents guidance, which will quickly become stale. Update the wording in this section to be version-agnostic, or move the compatibility claim into a separate dated support matrix, while keeping the references to the Kilo CLI commands and the structured review prompt fallback intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@scripts/hivekeep-local-review.ts`:
- Around line 4-6: The argv/env parsing in readArg and the provider/mode
dispatch should validate values before casting, since a flag token like --mode
or an invalid HIVEKEEP_LOCAL_REVIEW_PROVIDER can currently be treated as a real
ReviewProvider/ReviewMode. Update the parsing around readArg, the provider/mode
selection, and the dispatch logic to reject any value that is missing, starts
with --, or is not one of the allowed ReviewProvider / 'all' / ReviewMode
options, and fail fast with a clear error instead of defaulting into the Kilo
path.
In `@src/server/services/local-review.test.ts`:
- Around line 45-47: The local-review test is still invoking the real pipeline
through runLocalCodeReview, so it depends on the host’s CodeRabbit CLI state and
can become flaky. Update the test to stub the provider availability/runner used
by runLocalCodeReview so it always takes the skipped-review path, then assert
the artifact persistence behavior against that forced branch. Use the existing
local-review test case and the runLocalCodeReview flow to keep the test
deterministic.
In `@src/server/services/local-review.ts`:
- Around line 261-264: The persisted artifact is being written before the final
artifact path is assigned and before the results are updated, so the saved JSON
can diverge from the returned summary. In local-review.ts, adjust the flow
around persistArtifact and the run/result update logic so artifactPath is
finalized first, then the run object is updated with that path and the result
entries, and only after that persistArtifact is called to write the final state.
- Around line 185-190: The JSON parsing in parseJsonLines only handles
line-delimited records, so pretty-printed JSON objects with findings[] are
missed and can bypass review checks. Update parseJsonLines to first attempt
parsing the entire raw output as JSON (including the findings[] shape used by
the review response handling near the JSON object parsing logic), then fall back
to the existing line-by-line JSONL parsing if that fails. Keep the current
behavior for mixed log output and preserve the existing JSON parse filtering in
local-review.ts.
- Around line 297-301: In local-review.ts, the result classification in the run
completion logic currently only checks exec.exitCode and findings, so timed-out
provider executions can still be marked succeeded. Update the status computation
around the failed/statusValue/error/blockled return flow to also treat
exec.timedOut as a failure condition, and make sure the error message reflects
the timeout alongside the existing provider/exit-code fallback. Keep the change
localized to the result object assembly that uses summarize, evaluateGate, and
the exec result.
- Around line 110-127: The execReviewCli flow in local-review.ts still allows
Bun.spawn or cwd setup failures to escape and it buffers the entire
stdout/stderr before clamping. Wrap the spawn/init path in the existing guarded
handling so synchronous failures are caught, and update the
proc.stdout/proc.stderr handling to stream into byte-capped buffers instead of
using full Response(...).text() materialization; keep the timeout and exitCode
logic in execReviewCli consistent with the new streaming approach.
---
Nitpick comments:
In `@docs/local-review-agents.md`:
- Around line 67-69: The docs text currently hard-codes a specific Kilo CLI
version in the local-review-agents guidance, which will quickly become stale.
Update the wording in this section to be version-agnostic, or move the
compatibility claim into a separate dated support matrix, while keeping the
references to the Kilo CLI commands and the structured review prompt fallback
intact.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 039bbbaf-1e4a-4049-b87f-5857e081f83a
📒 Files selected for processing (11)
docs/local-review-agents.mdscripts/hivekeep-local-review.tssrc/client/locales/en.jsonsrc/client/locales/fr.jsonsrc/server/config.tssrc/server/services/local-review.test.tssrc/server/services/local-review.tssrc/server/services/toolboxes.tssrc/server/tools/code-review-tools.tssrc/server/tools/register.tssrc/test-helpers.ts
PR SummaryMedium Risk Overview Backend: New Client: Admin Reviewer Agents page ( Ops: Reviewed by Cursor Bugbot for commit cbfaaec. Bugbot is set up for automated code reviews on this repo. Configure here. |
…vider names parseJsonLines now falls back to scanning embedded JSON objects when line-by-line parsing yields no events, handling pretty-printed payloads after log lines like [INFO]. runLocalCodeReview rejects unknown provider values instead of silently running Kilo.
- Mark provider runs as failed when execReviewCli times out, even if partial findings were parsed from truncated output - Reject invalid mode values in runLocalCodeReview and the hook script so typos cannot silently disable blocking gate behavior
Add readProvider() to hivekeep-local-review.ts so invalid --provider or HIVEKEEP_LOCAL_REVIEW_PROVIDER values exit 1 with a clear message, matching readMode() and documented hook exit semantics. Tighten parseReviewFindings so operational JSON objects with severity but no finding type/location are not treated as review findings, preventing false gate blocks in blocking mode.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
There are 4 total unresolved issues (including 2 from previous reviews).
Autofix Details
a a.id - b.id
StrReplace
path
/workspace/src/server/services/local-review.ts
old_string
function clampOutput(s: string): string {
const max = config.codeReview.maxOutputBytes
if (s.length <= max) return s
return […truncated ${s.length - max} chars from the head…]\n${s.slice(-max)}
}
new_string
function clampOutput(s: string a.id -b.id -a.id - b.id
StrReplace
path
/workspace/src/server/services/local-review.ts
old_string
async function runKilo(input: ReviewInput & { repoPath: string }): Promise {
const slash = await execReviewCli('kilo', kiloSlashCommandArgs(input), input.repoPath, input.repoPath, input.timeoutMs)
const slashRaw = [slash.stdout, slash.stderr].filter(Boolean).join('\n a.id - b.id
if (slash.exitCode === 0 || parseReviewFindings('kilo', slashRaw).length > 0) return { ...slash, localReviewMode: 'slash-command' }
const fallback = await execReviewCli('kilo', kiloPromptFallbackArgs(input), input.repoPath, input.timeoutMs)
return {
...fallback,
stdout: fallback.stdout,
stderr: [Kilo slash-command local review failed; used prompt fallback., slashRaw, fallback.stderr].filter(Boolean).join('\n'),
localReviewMode: 'prompt-fallback',
}
}
new_string
async function runKilo(input: ReviewInput & { repoPath: string }): Promise {
const budgetMs = input.timeoutMs ?? config.codeReview.defaultTimeoutMs
const deadline = Date.now() + budgetMs
const remainingMs = () => Math.max(0, a.id - b.id)
const slash = await execReviewCli('kilo', kiloSlashCommandArgs(input), input.repoPath, remainingMs())
const slashRaw = [slash.stdout, slash.stderr].filter(Boolean).join('\n')
a.id - b.id
if (slash.exitCode === 0 || parseReviewFindings('kilo', slashRaw).length > 0) return { ...slash, localReviewMode: 'slash-command' }
const left = remainingMs()
if (left <= 0) return { ...slash, localReviewMode: 'slash-command' }
const fallback = await execReviewCli('kilo', kiloPromptFallbackArgs(input), input.repoPath, left)
return {
...fallback,
stdout: fallback.stdout,
stderr: [Kilo slash-command local review failed; used prompt fallback., slashRaw, fallback.stderr].filter(Boolean).join('\n'),
timedOut: fallback.timedOut || slash.timedOut,
localReviewMode: 'prompt-fallback',
}
}
You can send follow-ups to the cloud agent here.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/server/services/local-review.ts`:
- Around line 461-465: The slash-command path in runOneProvider/local-review
should not fall through to prompt fallback when execReviewCli('kilo',
kiloSlashCommandArgs(...)) times out without parseable findings. Update the
slash result handling so a timeout from the initial slash-command attempt is
returned or propagated immediately instead of continuing to
kiloPromptFallbackArgs, preserving the timed-out status in the slash-command
branch and preventing the provider from being marked successful.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: fc045ce8-0693-41f0-83f3-b29e07958bec
📒 Files selected for processing (4)
docs/local-review-agents.mdscripts/hivekeep-local-review.tssrc/server/services/local-review.test.tssrc/server/services/local-review.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- scripts/hivekeep-local-review.ts
- docs/local-review-agents.md
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 4 total unresolved issues (including 3 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Kilo slash ignores base ref
- Kilo slash-command args now append base or baseCommit to
/local-review(e.g./local-review origin/main) so the CLI reviews the requested diff scope, matching CodeRabbit behavior.
- Kilo slash-command args now append base or baseCommit to
You can send follow-ups to the cloud agent here.
Append base or baseCommit as slash-command arguments so Kilo reviews the same diff scope as CodeRabbit and the recorded review metadata.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Hook ignores configured default mode
- Changed the hook script fallback from hardcoded 'advisory' to config.codeReview.defaultMode so HIVEKEEP_CODE_REVIEW_MODE blocking mode applies consistently to pre-push hooks.
You can send follow-ups to the cloud agent here.
Use config.codeReview.defaultMode (HIVEKEEP_CODE_REVIEW_MODE) as the hook fallback instead of hardcoding advisory, so pre-push exit codes match in-app run_local_code_review blocking semantics.
Merge per-task env (e.g. HIVEKEEP_GH_TOKEN) into CodeRabbit/Kilo subprocess spawns via resolveToolEnv, matching run_shell behavior for worktree reviews. Base Kilo auth heuristics on kilo auth list output only so unrelated config check "not configured" messages no longer block reviews.
|
Bugbot Autofix prepared fixes for both issues found in the latest run.
Preview (5746e97d17)diff --git a/docs/local-review-agents.md b/docs/local-review-agents.md
new file mode 100644
--- /dev/null
+++ b/docs/local-review-agents.md
@@ -1,0 +1,125 @@
+# Local review agents: CodeRabbit and Kilo Code
+
+Hivekeep exposes two in-house local code reviewers as first-class reviewer providers rather than arbitrary shell commands:
+
+- **CodeRabbit** (`coderabbit`) through the CodeRabbit CLI (`cr` or `coderabbit`).
+- **Kilo Code** (`kilo`) through the Kilo CLI.
+
+The goal is a hook-style workflow that can run before push or PR creation, persist review artifacts, and give Agents/users a structured gating decision without making every commit depend on network services by default.
+
+## Workflow
+
+1. An Agent or hook calls `run_local_code_review` (or `bun scripts/hivekeep-local-review.ts`).
+2. Hivekeep resolves the repo/worktree path and records review metadata: provider, base/head, mode, timestamps, findings, and artifact path.
+3. Each reviewer runs as a dedicated provider adapter:
+ - CodeRabbit: `cr review --agent --dir <repo> --light` plus `--base` / `--base-commit` when supplied.
+ - Kilo Code: Kilo's documented local-review slash commands through `kilo run --format json --auto --dir <repo> /local-review` (or `/local-review-uncommitted` for working-tree-only review).
+4. Kilo falls back to a structured prompt (`kilo run --format json --auto --dir <repo> <review prompt>`) only if the slash-command run fails without parseable findings.
+5. JSON-line or JSON-object output is parsed into `ReviewFinding[]`.
+6. The gate blocks automatic push/PR only in `blocking` mode when any `critical` or `major` findings exist. Advisory mode always reports but does not block.
+7. A durable JSON artifact is written under `config.codeReview.artifactDir` (default `data/code-reviews`).
+
+## Tools
+
+- `list_local_reviewers` — lists CodeRabbit and Kilo Code status.
+- `check_code_review_auth` — checks CLI install/auth/doctor state for one or both reviewers.
+- `run_local_code_review` — runs reviewers and returns structured results.
+
+The tools are available to main Agents and sub-Agents and are included in the built-in `code` toolbox.
+
+## Hook runner
+
+Advisory pre-push example:
+
+```bash
+bun scripts/hivekeep-local-review.ts --provider all --mode advisory --base origin/main
+```
+
+Blocking pre-PR example:
+
+```bash
+bun scripts/hivekeep-local-review.ts --provider all --mode blocking --base origin/main
+```
+
+Exit codes:
+
+- `0` — review passed or advisory findings were reported.
+- `1` — reviewer failed in blocking mode.
+- `2` — blocking mode found major/critical issues.
+
+Hivekeep does **not** install this as a mandatory git hook automatically. Teams can opt in by calling the script from `.git/hooks/pre-push`, a PR creation wrapper, or CI.
+
+## Authentication and data flow
+
+Never put secrets in prompts or repo files.
+
+### CodeRabbit
+
+Install and authenticate the CodeRabbit CLI using CodeRabbit's documented setup. Hivekeep checks:
+
+- `cr --version` or `coderabbit --version`
+- `cr auth status --agent`
+- `cr doctor`
+
+Reviews are run with `cr review --agent`; review data is sent wherever the CodeRabbit CLI sends it under its authenticated account.
+
+### Kilo Code
+
+The Kilo CLI was verified locally during implementation. Its help exposes the scriptable runner contract:
+
+```bash
+kilo run --format json --auto --dir <repo> <message-or-slash-command>
+```
+
+The Kilo CLI docs at <https://kilo.ai/docs/code-with-ai/platforms/cli> document:
+
+- `kilo run [message..]` with `--format json` for raw JSON events.
+- `--auto` autonomous mode, including exit codes (`0` success, `124` timeout, `1` error).
+- `--dir` to choose the working directory.
+- built-in slash commands `/local-review` and `/local-review-uncommitted` under "Local Code Reviews".
+
+Hivekeep therefore treats this as Kilo's primary local-review contract and runs:
+
+```bash
+kilo run --format json --auto --dir <repo> /local-review
+```
+
+For explicitly working-tree-only review (`head: "working tree"` with no base/base commit), Hivekeep uses:
+
+```bash
+kilo run --format json --auto --dir <repo> /local-review-uncommitted
+```
+
+The provider status/result exposes `localReviewMode: "slash-command"` for this path. If the slash command exits non-zero and produces no parseable findings, Hivekeep retries once with the older structured prompt fallback and marks `localReviewMode: "prompt-fallback"` in the result so callers can distinguish native Kilo review from fallback review.
+
+Observed local behavior on this branch:
+
+- `/local-review` returned JSON events and spawned Kilo's built-in "Run local review" task. It produced findings against the branch diff without modifying files.
+- `/local-review-uncommitted` returned JSON events and reported no findings on a clean working tree.
+- Top-level commands such as `kilo review`, `kilo local-review`, `kilo reviews`, and `kilo code-review` are not separate commands in 7.3.44; they print top-level help. The stable entrypoint is the slash command through `kilo run`.
+- `kilo run --command local-review` did not complete in a short safety test, so Hivekeep does not use it.
+
+Review data is sent to the model/provider configured in Kilo. Use `kilo auth`, `kilo models`, `kilo profile`, and `kilo debug` locally to verify account/provider state.
+
+## Artifact schema
+
+Artifacts contain:
+
+- run id, mode, status, blocked flag
+- repo path, base/baseCommit/head
+- per-provider status and raw capped output
+- normalized findings: severity, confidence, title, message, file/line, rule id
+- summary suitable for chat/task reporting
+
+This JSON persistence is deliberately small and filesystem-backed for the MVP. The service boundary in `src/server/services/local-review.ts` is ready to swap to a DB-backed persistence layer later.
+
+## Remediation loop
+
+The review result is structured so Hiro/Kaito or ticket-bound sub-Agents can consume findings, fix high-severity issues, and rerun review before push/PR. In blocking mode, major/critical findings should prevent automatic push or PR creation unless a human explicitly overrides the gate.
+
+## Deferred enhancements
+
+- Rich React renderer for review artifacts/findings.
+- DB migration and review-run history UI.
+- Richer extraction for Kilo's nested task output if Kilo publishes a tighter machine-readable finding schema than JSON events containing review text.
+- Automatic PR wrapper integration after the local runner has settled.
diff --git a/scripts/hivekeep-local-review.ts b/scripts/hivekeep-local-review.ts
new file mode 100644
--- /dev/null
+++ b/scripts/hivekeep-local-review.ts
@@ -1,0 +1,41 @@
+#!/usr/bin/env bun
+import { config } from '@/server/config'
+import { runLocalCodeReview, type ReviewProvider, type ReviewMode } from '@/server/services/local-review'
+
+function readArg(name: string): string | undefined {
+ const i = process.argv.indexOf(name)
+ if (i < 0) return undefined
+ const value = process.argv[i + 1]
+ return value && !value.startsWith('-') ? value : undefined
+}
+
+function has(name: string): boolean {
+ return process.argv.includes(name)
+}
+
+function readMode(): ReviewMode {
+ const raw = readArg('--mode') ?? process.env.HIVEKEEP_LOCAL_REVIEW_MODE ?? config.codeReview.defaultMode
+ if (raw === 'advisory' || raw === 'blocking') return raw
+ console.error(`Invalid review mode: ${raw}. Valid values: advisory, blocking`)
+ process.exit(1)
+}
+
+function readProvider(): ReviewProvider | 'all' {
+ const raw = readArg('--provider') ?? process.env.HIVEKEEP_LOCAL_REVIEW_PROVIDER ?? 'all'
+ if (raw === 'coderabbit' || raw === 'kilo' || raw === 'all') return raw
+ console.error(`Invalid review provider: ${raw}. Valid values: coderabbit, kilo, all`)
+ process.exit(1)
+}
+
+const provider = readProvider()
+const mode = readMode()
+const repoPath = readArg('--repo') ?? process.cwd()
+const base = readArg('--base') ?? process.env.HIVEKEEP_LOCAL_REVIEW_BASE
+const baseCommit = readArg('--base-commit') ?? process.env.HIVEKEEP_LOCAL_REVIEW_BASE_COMMIT
+const head = readArg('--head') ?? process.env.HIVEKEEP_LOCAL_REVIEW_HEAD
+const light = !has('--full')
+
+const result = await runLocalCodeReview({ repoPath, provider, mode, base, baseCommit, head, light })
+console.log(JSON.stringify(result, null, 2))
+if (result.blocked) process.exit(2)
+if (result.status === 'failed' && mode === 'blocking') process.exit(1)
diff --git a/src/client/locales/en.json b/src/client/locales/en.json
--- a/src/client/locales/en.json
+++ b/src/client/locales/en.json
@@ -2862,7 +2862,10 @@
"update_project_knowledge": "Update Project Knowledge",
"delete_project_knowledge": "Delete Project Knowledge",
"pin_project_knowledge": "Pin Project Knowledge",
- "get_mini_app_backend_status": "Mini-App Backend Status"
+ "get_mini_app_backend_status": "Mini-App Backend Status",
+ "list_local_reviewers": "List Local Reviewers",
+ "check_code_review_auth": "Check Code Review Auth",
+ "run_local_code_review": "Run Local Code Review"
},
"renderers": {
"rawJson": "Raw JSON",
diff --git a/src/client/locales/fr.json b/src/client/locales/fr.json
--- a/src/client/locales/fr.json
+++ b/src/client/locales/fr.json
@@ -2862,7 +2862,10 @@
"update_project_knowledge": "Modifier une connaissance projet",
"delete_project_knowledge": "Supprimer une connaissance projet",
"pin_project_knowledge": "Épingler une connaissance projet",
- "get_mini_app_backend_status": "Statut du backend de Mini-App"
+ "get_mini_app_backend_status": "Statut du backend de Mini-App",
+ "list_local_reviewers": "Lister les reviewers locaux",
+ "check_code_review_auth": "Vérifier l’authentification de revue de code",
+ "run_local_code_review": "Lancer une revue de code locale"
},
"renderers": {
"rawJson": "JSON brut",
diff --git a/src/server/config.ts b/src/server/config.ts
--- a/src/server/config.ts
+++ b/src/server/config.ts
@@ -541,6 +541,16 @@
worktreeSweepIntervalMin: Number(process.env.HIVEKEEP_WORKTREE_SWEEP_INTERVAL_MIN ?? 5),
},
+ codeReview: {
+ /** Durable local-review JSON artifacts. Defaults outside Agent workspaces. */
+ artifactDir: process.env.HIVEKEEP_CODE_REVIEW_DIR ?? `${dataDir}/code-reviews`,
+ /** Advisory by default: report findings without making network CLIs a hard local gate. */
+ defaultMode: (process.env.HIVEKEEP_CODE_REVIEW_MODE === 'blocking' ? 'blocking' : 'advisory') as 'advisory' | 'blocking',
+ defaultTimeoutMs: Number(process.env.HIVEKEEP_CODE_REVIEW_TIMEOUT_MS ?? 300_000),
+ maxTimeoutMs: Number(process.env.HIVEKEEP_CODE_REVIEW_MAX_TIMEOUT_MS ?? 900_000),
+ maxOutputBytes: Number(process.env.HIVEKEEP_CODE_REVIEW_MAX_OUTPUT_BYTES ?? 256 * 1024),
+ },
+
upload: {
dir: process.env.UPLOAD_DIR ?? `${dataDir}/uploads`,
maxFileSizeMb: Number(process.env.UPLOAD_MAX_FILE_SIZE ?? 50),
diff --git a/src/server/services/local-review.test.ts b/src/server/services/local-review.test.ts
new file mode 100644
--- /dev/null
+++ b/src/server/services/local-review.test.ts
@@ -1,0 +1,213 @@
+import { afterEach, describe, expect, it } from 'bun:test'
+import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
+import { join } from 'path'
+import { tmpdir } from 'os'
+import { config } from '@/server/config'
+import { _LOCAL_REVIEW_INTERNALS_FOR_TEST, runLocalCodeReview } from './local-review'
+
+const { parseReviewFindings, evaluateGate, parseJsonLines, kiloSlashCommandArgs, kiloPromptFallbackArgs } = _LOCAL_REVIEW_INTERNALS_FOR_TEST
+const originalPath = process.env.PATH
+const originalArtifactDir = config.codeReview.artifactDir
+const mutableCodeReviewConfig = config.codeReview as { artifactDir: string }
+
+afterEach(() => {
+ process.env.PATH = originalPath
+ mutableCodeReviewConfig.artifactDir = originalArtifactDir
+})
+
+function makeFakeBin(name: string, body: string): string {
+ const root = mkdtempSync(join(tmpdir(), 'hivekeep-local-review-test-'))
+ const bin = join(root, name)
+ writeFileSync(bin, `#!/usr/bin/env bash\n${body}\n`)
+ chmodSync(bin, 0o755)
+ process.env.PATH = `${root}:${originalPath ?? ''}`
+ return root
+}
+
+describe('local-review parsing', () => {
+ it('parses JSON-line CodeRabbit findings', () => {
+ const raw = [
+ JSON.stringify({ type: 'finding', severity: 'critical', title: 'SQL injection', message: 'Use parameters', file: 'src/db.ts', line: 12, confidence: 'high' }),
+ JSON.stringify({ event: 'complete', findings: [{ severity: 'minor', title: 'Naming', path: 'src/a.ts' }] }),
+ ].join('\n')
+ const findings = parseReviewFindings('coderabbit', raw)
+ expect(findings).toHaveLength(2)
+ expect(findings[0]).toMatchObject({ provider: 'coderabbit', severity: 'critical', file: 'src/db.ts', line: 12, confidence: 'high' })
+ expect(findings[1]).toMatchObject({ severity: 'minor', file: 'src/a.ts' })
+ })
+
+ it('parses pretty-printed whole JSON objects before JSONL fallback', () => {
+ const raw = JSON.stringify({ findings: [{ severity: 'major', title: 'Pretty', file: 'src/pretty.ts', line: 7 }] }, null, 2)
+ const events = parseJsonLines(raw)
+ expect(events).toHaveLength(1)
+ expect(parseReviewFindings('kilo', raw)[0]).toMatchObject({ severity: 'major', title: 'Pretty', file: 'src/pretty.ts' })
+ })
+
+ it('ignores non-json log lines while keeping valid JSON events', () => {
+ const events = parseJsonLines('hello\n{"type":"finding","title":"x"}\nnot json')
+ expect(events).toHaveLength(1)
+ })
+
+ it('parses Kilo slash-command findings from nested results', () => {
+ const findings = parseReviewFindings('kilo', JSON.stringify({ findings: [{ severity: 'high', title: 'Race', description: 'Shared state', location: { path: 'src/race.ts', line: '5' } }] }))
+ expect(findings).toHaveLength(1)
+ expect(findings[0]).toMatchObject({ provider: 'kilo', severity: 'major', file: 'src/race.ts', line: 5 })
+ })
+
+ it('does not duplicate nested finding containers or parse candidate string fields twice', () => {
+ const raw = JSON.stringify({
+ type: 'finding',
+ severity: 'major',
+ title: 'Parent container',
+ message: '| severity | title | file |\n|---|---|---|\n| high | Nested table | src/table.ts |',
+ findings: [{ severity: 'minor', title: 'Child finding', file: 'src/child.ts' }],
+ })
+ const findings = parseReviewFindings('kilo', raw)
+ expect(findings).toHaveLength(1)
+ expect(findings[0]).toMatchObject({ severity: 'minor', title: 'Child finding', file: 'src/child.ts' })
+ })
+
+ it('parses CodeRabbit agent findings with fileName and codegenInstructions fields', () => {
+ const findings = parseReviewFindings('coderabbit', JSON.stringify({ type: 'finding', severity: 'major', fileName: 'src/review.ts', codegenInstructions: 'Fix this path' }))
+ expect(findings).toHaveLength(1)
+ expect(findings[0]).toMatchObject({ provider: 'coderabbit', severity: 'major', file: 'src/review.ts', title: 'Fix this path' })
+ })
+
+ it('parses Kilo JSON event text containing a Markdown finding table', () => {
+ const raw = JSON.stringify({
+ type: 'text',
+ text: '| severity | title | message | file | line | confidence |\n|---|---|---|---|---:|---|\n| high | Missing guard | Add validation | src/a.ts | 42 | high |',
+ })
+ const findings = parseReviewFindings('kilo', raw)
+ expect(findings).toHaveLength(1)
+ expect(findings[0]).toMatchObject({ provider: 'kilo', severity: 'major', title: 'Missing guard', file: 'src/a.ts', line: 42, confidence: 'high' })
+ })
+})
+
+describe('Kilo local-review adapter', () => {
+ it('builds the documented slash-command invocation before prompt fallback', () => {
+ expect(kiloSlashCommandArgs({ repoPath: '/repo', base: 'origin/main' })).toEqual(['run', '--format', 'json', '--auto', '--dir', '/repo', '/local-review origin/main'])
+ expect(kiloSlashCommandArgs({ repoPath: '/repo', baseCommit: 'abc123' })).toEqual(['run', '--format', 'json', '--auto', '--dir', '/repo', '/local-review abc123'])
+ expect(kiloSlashCommandArgs({ repoPath: '/repo', head: 'working tree' })).toEqual(['run', '--format', 'json', '--auto', '--dir', '/repo', '/local-review-uncommitted'])
+ expect(kiloPromptFallbackArgs({ repoPath: '/repo', base: 'origin/main' }).at(-1)).toContain('dedicated local code reviewer')
+ })
+
+ it('records slash-command adapter mode in Kilo results', async () => {
+ const root = makeFakeBin('kilo', `
+if [[ "$1" == "--version" ]]; then echo "7.3.44"; exit 0; fi
+if [[ "$1" == "auth" ]]; then echo 'Kilo Gateway credential active'; exit 0; fi
+if [[ "$1" == "config" ]]; then echo 'ok'; exit 0; fi
+if [[ "$1" == "run" && "$7" == /local-review* ]]; then echo '{"findings":[{"severity":"minor","title":"Kilo nit","file":"src/kilo.ts"}]}'; exit 0; fi
+echo "unexpected args: $*" >&2
+exit 1
+`)
+ try {
+ mkdirSync(join(root, 'repo'), { recursive: true })
+ const result = await runLocalCodeReview({ repoPath: join(root, 'repo'), provider: 'kilo', base: 'origin/main', mode: 'advisory', timeoutMs: 1000 })
+ expect(result.results[0]).toMatchObject({ provider: 'kilo', status: 'succeeded', localReviewMode: 'slash-command' })
+ expect(result.findings[0]).toMatchObject({ provider: 'kilo', severity: 'minor', file: 'src/kilo.ts' })
+ } finally {
+ rmSync(root, { recursive: true, force: true })
+ }
+ })
+
+ it('falls back to prompt mode only when Kilo slash command fails', async () => {
+ const root = makeFakeBin('kilo', `
+if [[ "$1" == "--version" ]]; then echo "7.3.44"; exit 0; fi
+if [[ "$1" == "auth" ]]; then echo 'Kilo Gateway credential active'; exit 0; fi
+if [[ "$1" == "config" ]]; then echo 'ok'; exit 0; fi
+if [[ "$1" == "run" && "$7" == /local-review* ]]; then echo 'slash failed' >&2; exit 1; fi
+if [[ "$1" == "run" ]]; then echo '{"findings":[{"severity":"major","title":"Fallback finding","file":"src/fallback.ts"}]}'; exit 0; fi
+exit 1
+`)
+ try {
+ mkdirSync(join(root, 'repo'), { recursive: true })
+ const result = await runLocalCodeReview({ repoPath: join(root, 'repo'), provider: 'kilo', base: 'origin/main', mode: 'advisory', timeoutMs: 1000 })
+ expect(result.results[0]).toMatchObject({ provider: 'kilo', status: 'succeeded', localReviewMode: 'prompt-fallback' })
+ expect(result.results[0]?.rawOutput).toContain('slash-command local review failed')
+ expect(result.findings[0]).toMatchObject({ provider: 'kilo', severity: 'major', file: 'src/fallback.ts' })
+ } finally {
+ rmSync(root, { recursive: true, force: true })
+ }
+ })
+
+ it('does not fall back to prompt mode after a timed-out slash command', async () => {
+ const root = makeFakeBin('kilo', `
+if [[ "$1" == "--version" ]]; then echo "7.3.44"; exit 0; fi
+if [[ "$1" == "auth" ]]; then echo 'Kilo Gateway credential active'; exit 0; fi
+if [[ "$1" == "config" ]]; then echo 'ok'; exit 0; fi
+if [[ "$1" == "run" && "$7" == /local-review* ]]; then sleep 1; echo '{"findings":[{"severity":"major","title":"late","file":"src/late.ts"}]}'; exit 0; fi
+if [[ "$1" == "run" ]]; then echo '{"findings":[{"severity":"critical","title":"should not run","file":"src/fallback.ts"}]}'; exit 0; fi
+exit 1
+`)
+ try {
+ mkdirSync(join(root, 'repo'), { recursive: true })
+ const result = await runLocalCodeReview({ repoPath: join(root, 'repo'), provider: 'kilo', base: 'origin/main', mode: 'advisory', timeoutMs: 50 })
+ expect(result.results[0]).toMatchObject({ provider: 'kilo', status: 'failed', localReviewMode: 'slash-command' })
+ expect(result.results[0]?.error).toContain('timed out')
+ expect(result.results[0]?.rawOutput).not.toContain('should not run')
+ expect(result.findings).toHaveLength(0)
+ } finally {
+ rmSync(root, { recursive: true, force: true })
+ }
+ })
+})
+
+describe('local-review gating', () => {
+ it('blocks major/critical findings only in blocking mode', () => {
+ const findings = parseReviewFindings('coderabbit', JSON.stringify({ findings: [{ severity: 'major', title: 'Bug' }] }))
+ expect(evaluateGate(findings, 'blocking')).toBe(true)
+ expect(evaluateGate(findings, 'advisory')).toBe(false)
+ })
+
+ it('allows advisory/no high severity findings', () => {
+ const findings = parseReviewFindings('coderabbit', JSON.stringify({ findings: [{ severity: 'minor', title: 'Nit' }] }))
+ expect(evaluateGate(findings, 'blocking')).toBe(false)
+ })
+})
+
+describe('local-review artifacts', () => {
+ it('persists finalized artifact paths and provider metadata', async () => {
+ const root = makeFakeBin('cr', `
+if [[ "$1" == "--version" ]]; then echo "0.0.0-test"; exit 0; fi
+if [[ "$1" == "auth" ]]; then echo '{"authenticated":true}'; exit 0; fi
+if [[ "$1" == "doctor" ]]; then echo 'doctor ok'; exit 0; fi
+if [[ "$1" == "review" ]]; then echo '{"type":"finding","severity":"minor","title":"Nit","file":"src/a.ts"}'; exit 0; fi
+exit 1
+`)
+ try {
+ mkdirSync(join(root, 'repo'), { recursive: true })
+ mutableCodeReviewConfig.artifactDir = join(root, 'artifacts')
+ const result = await runLocalCodeReview({ repoPath: join(root, 'repo'), provider: 'coderabbit', mode: 'advisory', timeoutMs: 1000 })
+ expect(result.artifactPath.endsWith(`${result.id}.json`)).toBe(true)
+ const saved = JSON.parse(readFileSync(result.artifactPath, 'utf8'))
+ expect(saved.artifactPath).toBe(result.artifactPath)
+ expect(saved.results[0].artifactPath).toBe(result.artifactPath)
+ expect(saved.results[0]).toMatchObject({ provider: 'coderabbit', repoPath: join(root, 'repo'), status: 'succeeded' })
+ expect(saved.findings[0]).toMatchObject({ provider: 'coderabbit', severity: 'minor', file: 'src/a.ts' })
+ } finally {
+ rmSync(root, { recursive: true, force: true })
+ }
+ })
+
+ it('reports all-skipped advisory runs as skipped and fails closed on blocking readiness errors', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'hivekeep-local-review-empty-path-'))
+ process.env.PATH = root
+ mkdirSync(join(root, 'repo'), { recursive: true })
+ try {
+ mutableCodeReviewConfig.artifactDir = join(root, 'artifacts')
+ const advisory = await runLocalCodeReview({ repoPath: join(root, 'repo'), provider: 'coderabbit', mode: 'advisory', timeoutMs: 1000 })
+ expect(advisory.blocked).toBe(false)
+ expect(advisory.status).toBe('skipped')
+ expect(advisory.results[0]).toMatchObject({ status: 'skipped', blocked: false })
+
+ const blocking = await runLocalCodeReview({ repoPath: join(root, 'repo'), provider: 'coderabbit', mode: 'blocking', timeoutMs: 1000 })
+ expect(blocking.blocked).toBe(false)
+ expect(blocking.status).toBe('failed')
+ expect(blocking.results[0]).toMatchObject({ status: 'failed', blocked: false })
+ expect(blocking.results[0]?.error).toContain('CodeRabbit CLI not found')
+ } finally {
+ rmSync(root, { recursive: true, force: true })
+ }
+ })
+})
diff --git a/src/server/services/local-review.ts b/src/server/services/local-review.ts
new file mode 100644
--- /dev/null
+++ b/src/server/services/local-review.ts
@@ -1,0 +1,508 @@
+import { mkdirSync, writeFileSync } from 'fs'
+import { join, resolve } from 'path'
+import { randomUUID } from 'crypto'
+import { config } from '@/server/config'
+
+export type ReviewProvider = 'coderabbit' | 'kilo'
+export type ReviewRunStatus = 'queued' | 'running' | 'succeeded' | 'failed' | 'skipped'
+export type ReviewSeverity = 'info' | 'minor' | 'major' | 'critical'
+export type ReviewConfidence = 'low' | 'medium' | 'high'
+export type ReviewMode = 'advisory' | 'blocking'
+export type LocalReviewAdapterMode = 'native' | 'slash-command' | 'prompt-fallback'
+
+export interface ReviewFinding {
+ id: string
+ provider: ReviewProvider
+ severity: ReviewSeverity
+ confidence: ReviewConfidence
+ title: string
+ message: string
+ file?: string
+ line?: number
+ endLine?: number
+ ruleId?: string
+ raw?: unknown
+}
+
+export interface ReviewInput {
+ repoPath: string
+ provider?: ReviewProvider | 'all'
+ base?: string
+ baseCommit?: string
+ head?: string
+ mode?: ReviewMode
+ light?: boolean
+ taskId?: string
+ agentId?: string
+ timeoutMs?: number
+ env?: Record<string, string | undefined>
+}
+
+export interface ReviewProviderStatus {
+ provider: ReviewProvider
+ displayName: string
+ installed: boolean
+ authenticated: boolean | null
+ version?: string
+ authStatus?: string
+ doctor?: string
+ localReviewMode?: LocalReviewAdapterMode
+ error?: string
+}
+
+export interface ReviewResult {
+ id: string
+ provider: ReviewProvider
+ status: ReviewRunStatus
+ startedAt: string
+ completedAt?: string
+ repoPath: string
+ base?: string
+ baseCommit?: string
+ head?: string
+ mode: ReviewMode
+ light: boolean
+ findings: ReviewFinding[]
+ summary: string
+ rawOutput?: string
+ error?: string
+ artifactPath?: string
+ localReviewMode?: LocalReviewAdapterMode
+ blocked: boolean
+}
+
+export interface ReviewRunSummary {
+ id: string
+ status: ReviewRunStatus
+ mode: ReviewMode
+ blocked: boolean
+ results: ReviewResult[]
+ findings: ReviewFinding[]
+ artifactPath: string
+ summary: string
+}
+
+export const LOCAL_REVIEW_PROVIDERS: Array<{ id: ReviewProvider; displayName: string; description: string }> = [
+ { id: 'coderabbit', displayName: 'CodeRabbit', description: 'CodeRabbit CLI-backed local reviewer (`cr review --agent --light`).' },
+ { id: 'kilo', displayName: 'Kilo Code', description: "Kilo CLI-backed local reviewer using Kilo's `/local-review` slash command via `kilo run --format json --auto`." },
+]
+
+interface ExecResult {
+ exitCode: number
+ stdout: string
+ stderr: string
+ timedOut: boolean
+ localReviewMode?: LocalReviewAdapterMode
+}
+
+function asString(v: unknown): string | undefined {
+ return typeof v === 'string' && v.trim().length > 0 ? v : undefined
+}
+
+function clampOutput(s: string): string {
+ const max = config.codeReview.maxOutputBytes
+ if (s.length <= max) return s
+ const edge = Math.max(1, Math.floor(max / 2))
+ return `${s.slice(0, edge)}\n[…truncated ${s.length - max} chars from the middle…]\n${s.slice(-edge)}`
+}
+
+function redactSensitiveOutput(s: string): string {
+ return s
+ .replace(/\b(Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, '$1[redacted]')
+ .replace(/\b(token|api[_-]?key|secret|password|authorization)(\s*[:=]\s*)([^\s'"`,;]+)/gi, '$1$2[redacted]')
+}
+
+function parseOptionalLine(value: unknown): number | undefined {
+ if (typeof value === 'number' && Number.isFinite(value)) return value
+ if (typeof value === 'string' && /^\d+$/.test(value.trim())) return Number(value.trim())
+ return undefined
+}
+
+export async function execReviewCli(
+ command: string,
+ args: string[],
+ cwd: string,
+ timeoutMs = config.codeReview.defaultTimeoutMs,
+ env?: Record<string, string | undefined>,
+): Promise<ExecResult> {
+ let proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
+ try {
+ proc = Bun.spawn([command, ...args], {
+ cwd,
+ env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', ...env },
+ stdout: 'pipe',
+ stderr: 'pipe',
+ })
+ } catch (err) {
+ return { exitCode: 127, stdout: '', stderr: err instanceof Error ? err.message : String(err), timedOut: false }
+ }
+
+ let timedOut = false
+ const timer = setTimeout(() => {
+ timedOut = true
+ proc.kill()
+ }, Math.min(timeoutMs, config.codeReview.maxTimeoutMs))
+ try {
+ const [stdout, stderr, exitCode] = await Promise.all([
+ new Response(proc.stdout).text(),
+ new Response(proc.stderr).text(),
+ proc.exited,
+ ])
+ return { exitCode, stdout: clampOutput(stdout), stderr: clampOutput(stderr), timedOut }
+ } catch (err) {
+ return { exitCode: 127, stdout: '', stderr: err instanceof Error ? err.message : String(err), timedOut }
+ } finally {
+ clearTimeout(timer)
+ }
+}
+
+async function firstWorking(names: string[], args: string[], cwd: string, env?: Record<string, string | undefined>): Promise<{ name: string; result: ExecResult } | null> {
+ for (const name of names) {
+ const result = await execReviewCli(name, args, cwd, 10_000, env)
+ if (result.exitCode !== 127 && !/command not found/i.test(result.stderr)) return { name, result }
+ }
+ return null
+}
+
+export async function listLocalReviewers(repoPath = process.cwd(), env?: Record<string, string | undefined>): Promise<ReviewProviderStatus[]> {
+ return [await checkCodeRabbitAuth(repoPath, env), await checkKiloAuth(repoPath, env)]
+}
+
+export async function checkCodeRabbitAuth(repoPath = process.cwd(), env?: Record<string, string | undefined>): Promise<ReviewProviderStatus> {
+ const found = await firstWorking(['cr', 'coderabbit'], ['--version'], repoPath, env)
+ if (!found) return { provider: 'coderabbit', displayName: 'CodeRabbit', installed: false, authenticated: null, error: 'CodeRabbit CLI not found (`cr` or `coderabbit`).' }
+ const auth = await execReviewCli(found.name, ['auth', 'status', '--agent'], repoPath, 15_000, env)
+ const doctor = await execReviewCli(found.name, ['doctor'], repoPath, 30_000, env)
+ const authText = redactSensitiveOutput([auth.stdout, auth.stderr].filter(Boolean).join('\n').trim())
+ const doctorText = redactSensitiveOutput([doctor.stdout, doctor.stderr].filter(Boolean).join('\n').trim())
+ return {
+ provider: 'coderabbit',
+ displayName: 'CodeRabbit',
+ installed: true,
+ authenticated: auth.exitCode === 0,
+ version: found.result.stdout.trim() || found.result.stderr.trim(),
+ authStatus: authText || undefined,
+ doctor: doctorText || undefined,
+ localReviewMode: 'native',
+ error: auth.exitCode === 0 ? undefined : authText || 'CodeRabbit auth status failed.',
+ }
+}
+
+export async function checkKiloAuth(repoPath = process.cwd(), env?: Record<string, string | undefined>): Promise<ReviewProviderStatus> {
+ const found = await firstWorking(['kilo'], ['--version'], repoPath, env)
+ if (!found) return { provider: 'kilo', displayName: 'Kilo Code', installed: false, authenticated: null, error: 'Kilo CLI not found (`kilo`).' }
+ const auth = await execReviewCli('kilo', ['auth', 'list'], repoPath, 15_000, env)
+ const configCheck = await execReviewCli('kilo', ['config', 'check'], repoPath, 15_000, env)
+ const authText = redactSensitiveOutput([auth.stdout, auth.stderr].filter(Boolean).join('\n').trim())
+ const configText = redactSensitiveOutput([configCheck.stdout, configCheck.stderr].filter(Boolean).join('\n').trim())
+ const text = [authText, configText].filter(Boolean).join('\n')
+ const negativeAuth = /\b(no|not|missing|absent|invalid|expired|unauthorized|unauthenticated)\b.{0,40}\b(credential|credentials|auth|login|provider|account|token|key)s?\b|\bnot logged in\b/i.test(authText)
+ const positiveAuth = /\b(Kilo Gateway|OpenAI|oauth)\b|\bcredential(s)?\b.{0,40}\b(active|configured|found|available|connected|present)\b/i.test(authText)
+ const authenticated = auth.exitCode === 0 && positiveAuth && !negativeAuth
+ return {
+ provider: 'kilo',
+ displayName: 'Kilo Code',
+ installed: true,
+ authenticated,
+ version: found.result.stdout.trim() || found.result.stderr.trim(),
+ authStatus: text || undefined,
+ localReviewMode: 'slash-command',
+ error: authenticated ? undefined : text || 'Kilo auth list did not report configured credentials.',
+ }
+}
+
+export function parseJsonLines(raw: string): unknown[] {
+ const trimmedRaw = raw.trim()
+ if (trimmedRaw) {
+ try {
+ const parsed = JSON.parse(trimmedRaw)
+ return Array.isArray(parsed) ? parsed : [parsed]
+ } catch {
+ // Fall through to JSONL/log parsing.
+ }
+ }
+
+ const events: unknown[] = []
+ for (const line of raw.split(/\r?\n/)) {
+ const trimmed = line.trim()
+ if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) continue
+ try { events.push(JSON.parse(trimmed)) } catch { /* ignore non-json log lines */ }
+ }
+ if (events.length > 0) return events
+
+ if (trimmedRaw) {
+ for (let i = 0; i < trimmedRaw.length; i++) {
+ const ch = trimmedRaw[i]
+ if (ch !== '{' && ch !== '[') continue
+ try {
+ const parsed = JSON.parse(trimmedRaw.slice(i))
+ return Array.isArray(parsed) ? parsed : [parsed]
+ } catch {
+ // Try the next `{` or `[` (e.g. skip log prefixes like `[INFO]`).
+ }
+ }
+ }
+
+ return events
+}
+
+function severityFrom(value: unknown): ReviewSeverity {
+ const s = String(value ?? '').toLowerCase()
+ if (['critical', 'blocker', 'security'].includes(s)) return 'critical'
+ if (['major', 'high', 'error'].includes(s)) return 'major'
+ if (['minor', 'medium', 'warning', 'warn'].includes(s)) return 'minor'
+ return 'info'
+}
+
+function confidenceFrom(value: unknown): ReviewConfidence {
+ const s = String(value ?? '').toLowerCase()
+ if (s === 'high') return 'high'
+ if (s === 'low') return 'low'
+ return 'medium'
+}
+
+function findingFromObject(provider: ReviewProvider, obj: Record<string, unknown>, index: number): ReviewFinding | null {
+ const title = asString(obj.title) ?? asString(obj.message) ?? asString(obj.body) ?? asString(obj.description) ?? asString(obj.codegenInstructions)
+ if (!title) return null
+ const loc = (obj.location && typeof obj.location === 'object') ? obj.location as Record<string, unknown> : {}
+ return {
+ id: asString(obj.id) ?? `${provider}-${index + 1}`,
+ provider,
+ severity: severityFrom(obj.severity ?? obj.level ?? obj.priority),
+ confidence: confidenceFrom(obj.confidence),
+ title: title.slice(0, 240),
+ message: (asString(obj.message) ?? asString(obj.body) ?? asString(obj.description) ?? title).slice(0, 4000),
+ file: asString(obj.file) ?? asString(obj.fileName) ?? asString(obj.path) ?? asString(loc.path) ?? asString(loc.file),
+ line: parseOptionalLine(obj.line) ?? parseOptionalLine(loc.line),
+ endLine: parseOptionalLine(obj.endLine) ?? parseOptionalLine(loc.endLine),
+ ruleId: asString(obj.ruleId) ?? asString(obj.rule_id) ?? asString(obj.code),
+ raw: obj,
+ }
+}
+
+function parseEmbeddedJson(provider: ReviewProvider, text: string): ReviewFinding[] {
+ const trimmed = text.trim()
+ if (!trimmed) return []
+ const firstJson = Math.min(...['{', '['].map((ch) => {
+ const i = trimmed.indexOf(ch)
+ return i < 0 ? Number.POSITIVE_INFINITY : i
+ }))
+ if (!Number.isFinite(firstJson)) return []
+ const jsonLike = trimmed.slice(firstJson)
+ try {
+ return parseReviewFindings(provider, jsonLike)
+ } catch {
+ return []
+ }
+}
+
+function parseMarkdownFindingTable(provider: ReviewProvider, text: string): ReviewFinding[] {
+ const lines = text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith('|') && line.endsWith('|'))
+ if (lines.length < 3) return []
+ const header = lines[0]!.split('|').map((cell) => cell.trim().toLowerCase()).filter(Boolean)
+ const separator = lines[1]!
+ if (!header.includes('severity') || (!header.includes('title') && !header.includes('issue')) || !/^[|\s:-]+$/.test(separator)) return []
+ const idx = (name: string) => header.indexOf(name)
+ return lines.slice(2).map((line, i) => {
+ const cells = line.split('|').map((cell) => cell.trim()).filter((_, cellIndex, arr) => cellIndex > 0 && cellIndex < arr.length - 1)
+ const get = (name: string) => {
+ const index = idx(name)
+ return index >= 0 ? cells[index] : undefined
+ }
+ const fileLine = get('file:line') ?? get('file')
+ const parsedLocation = fileLine?.match(/^(.*):(\d+)$/)
+ return findingFromObject(provider, {
+ id: `${provider}-table-${i + 1}`,
+ severity: get('severity'),
+ title: get('title') ?? get('issue'),
+ message: get('message') ?? get('issue'),
+ file: parsedLocation?.[1] ?? fileLine,
+ line: Number(get('line') ?? parsedLocation?.[2]) || undefined,
+ confidence: get('confidence'),
+ }, i)
+ }).filter((f): f is ReviewFinding => Boolean(f))
+}
+
+const FINDING_ARRAY_KEYS = new Set(['findings', 'issues', 'comments', 'diagnostics'])
+
+function isFindingCandidate(obj: Record<string, unknown>): boolean {
+ const type = String(obj.type ?? obj.event ?? '').toLowerCase()
+ if (type.includes('finding') || type.includes('issue')) return true
+ return Boolean(obj.location || obj.file || obj.path || obj.fileName)
+}
+
+function isFindingArrayItem(obj: Record<string, unknown>): boolean {
+ return Boolean(obj.severity || obj.title || obj.message || obj.body || obj.description || obj.codegenInstructions)
+}
+
+export function parseReviewFindings(provider: ReviewProvider, raw: string): ReviewFinding[] {
+ const events = parseJsonLines(raw)
+ const candidates: Record<string, unknown>[] = []
+ const stringFindings: ReviewFinding[] = []
+ const seenStrings = new Set<string>()
+ const visit = (v: unknown, fromFindingArray = false) => {
+ if (Array.isArray(v)) return v.forEach((item) => visit(item, fromFindingArray))
+ if (typeof v === 'string') {
... diff truncated: showing 800 of 1111 linesYou can send follow-ups to the cloud agent here. |
…min guard - Reject traversal sequences in review run IDs and verify resolved artifact paths stay within the configured artifact directory - Persist finding state updates via validated artifactPathFor(runId) instead of trusting artifactPath from loaded JSON - Require admin role on all /api/reviewer-agents routes
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Gate summary ignores failed runs
- Moved runStatus before summary construction and append 'Gate: FAILED.' when runStatus is failed instead of always defaulting to 'Gate: passed/advisory.' when not blocked.
You can send follow-ups to the cloud agent here.
When a review run ends with status failed but has no major/critical findings, append 'Gate: FAILED.' instead of 'Gate: passed/advisory.'
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
There are 3 total unresolved issues (including 1 from previous review).
Bugbot Autofix is ON. A cloud agent has been kicked off to fix the reported issues.
Reviewed by Cursor Bugbot for commit a45e384. Configure here.
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_1dd0cc0b-5158-48d1-b80a-1fc11ab8b15b) |

Summary
Validation
Notes
Summary by CodeRabbit