Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/commit-sweeper.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,13 @@ disables Codex web search, and explicitly forbids network lookups. Repositories
without a configured profile are rejected (no foreign-profile fallback). Unlike the
hosted lane it never writes to GitHub — the local Markdown report is the only output.

By default `local-review` uses the **codex** engine. Pass `--engine claude` to drive a
**fixed `claude` CLI** through the same bounded runner instead — provider-neutral, with
zero extra dependencies. The claude engine uses your existing Claude auth; for a
subscription set `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`) and leave
`ANTHROPIC_API_KEY` unset. The binary is fixed (`codex`/`claude`), never
operator-configurable.

## Enable / Disable

Target repositories can disable hook-based dispatch with:
Expand Down
5 changes: 4 additions & 1 deletion src/codex-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export function runCodexProcess(options: {
stdoutPath?: string;
stderrPath?: string;
appServer?: CodexAppServerProcessOptions;
/** Override the spawned binary (defaults to codex) so the same bounded runner
* can drive another fixed review CLI, e.g. `claude`. */
command?: string;
}): CodexProcessResult {
const workDir = mkdtempSync(join(tmpdir(), "clawsweeper-codex-process-"));
const optionsPath = join(workDir, "options.json");
Expand All @@ -90,7 +93,7 @@ export function runCodexProcess(options: {
optionsPath,
JSON.stringify({
args: [...options.args],
command: codexProcessCommand(options.env),
command: options.command ?? codexProcessCommand(options.env),
timeoutMs: options.timeoutMs,
resultPath,
stdoutPath,
Expand Down
134 changes: 129 additions & 5 deletions src/commit-sweeper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,110 @@ function runCodex(options: {
return stripMarkdownFence(readFileSync(outputPath, "utf8"));
}

// Local-review Claude engine: drives a FIXED `claude` CLI through the same bounded
// runner as codex (no operator-configurable binary), credentials scrubbed via
// codexEnv, read-only tools. Auth is the caller's existing Claude auth — for a
// subscription set CLAUDE_CODE_OAUTH_TOKEN (and leave ANTHROPIC_API_KEY unset).
function runClaudeReview(options: {
targetDir: string;
targetRepo: string;
sha: string;
baseSha: string;
metadata: CommitMetadata;
timeoutMs: number;
workDir: string;
additionalPrompt: string;
}): string {
// The claude reviewer is read-only (Read/Grep/Glob, no shell), so unlike the
// sandboxed codex lane it cannot run `git` to inspect the range. Embed the full
// committed-range diff in the prompt so it reviews the actual change, not just the
// current HEAD files plus a changed-file list.
// `run` buffers the whole diff host-side (execFileSync). A huge whole-branch diff
// (vendored/generated/binary-ish blobs) can exceed the maxBuffer ceiling and throw
// before the cap below ever applies — degrade to a failure report, like the rest of
// the lane, instead of aborting the process with a stack trace. (codex avoids this by
// diffing inside its own sandbox.)
let rawDiff: string;
try {
rawDiff = run("git", ["diff", `${options.baseSha}..${options.sha}`], {
cwd: options.targetDir,
});
} catch (error) {
return failureReport({
targetRepo: options.targetRepo,
sha: options.sha,
baseSha: options.baseSha,
metadata: options.metadata,
detail: `failed to read range diff: ${
error instanceof Error ? error.message : String(error)
}`,
timeout: false,
});
}
const maxDiffBytes = 256 * 1024;
const diff =
rawDiff.length > maxDiffBytes
? `${rawDiff.slice(0, maxDiffBytes)}\n…(diff truncated at ${maxDiffBytes} bytes)…`
: rawDiff;
const prompt = `${promptForCommit({
targetDir: options.targetDir,
targetRepo: options.targetRepo,
sha: options.sha,
baseSha: options.baseSha,
metadata: options.metadata,
additionalPrompt: options.additionalPrompt,
})}

## Full Range Diff (\`${options.baseSha}..${options.sha}\`)

\`\`\`diff
${diff || "(empty diff)"}
\`\`\`
`;
// Capture the FULL stdout (not the runner's 64 KiB tail) so a large report's YAML
// front matter at the top is never truncated away.
const stdoutPath = join(options.workDir, "claude-stdout.log");
const result = runCodexProcess({
command: "claude",
args: ["-p", "--output-format", "text", "--allowedTools", "Read,Grep,Glob"],
cwd: options.targetDir,
env: codexEnv(),
input: prompt,
timeoutMs: options.timeoutMs,
stdoutPath,
});
const stdout = existsSync(stdoutPath) ? readFileSync(stdoutPath, "utf8") : result.stdout;
if (result.error || result.status !== 0) {
const timeout = codexProcessErrorCode(result.error) === "ETIMEDOUT";
const detail =
result.error instanceof Error
? `${result.error.message}\n${safeOutputTail(result.stderr) || safeOutputTail(stdout)}`
: `exit ${result.status ?? "unknown"}\n${
safeOutputTail(result.stderr) || safeOutputTail(stdout) || "No output."
}`;
return failureReport({
targetRepo: options.targetRepo,
sha: options.sha,
baseSha: options.baseSha,
metadata: options.metadata,
detail: detail.trim(),
timeout,
});
}
const markdown = stripMarkdownFence(stdout);
if (!markdown.trim()) {
return failureReport({
targetRepo: options.targetRepo,
sha: options.sha,
baseSha: options.baseSha,
metadata: options.metadata,
detail: "claude produced no output",
timeout: false,
});
}
return markdown;
}

function reviewCommand(args: Args): void {
const targetRepo = argString(args, "target_repo", DEFAULT_TARGET_REPO);
const targetDir = resolve(
Expand Down Expand Up @@ -422,6 +526,12 @@ export function localReviewAdditionalPrompt(
function localReviewCommand(args: Args): void {
const targetDir = resolve(argString(args, "target_dir", "."));
const baseBranch = argString(args, "base", "main");
// Validate the engine up front, before creating any run state.
const engine = argString(args, "engine", "codex");
if (engine !== "codex" && engine !== "claude") {
console.error(`[local-review] --engine must be "codex" or "claude", got "${engine}"`);
process.exit(1);
}
const reportDir = resolve(
argString(args, "report_dir", join(homedir(), ".clawsweeper-local-reviews")),
);
Expand Down Expand Up @@ -483,8 +593,22 @@ function localReviewCommand(args: Args): void {
`[local-review] repo=${targetRepo} profile=${profileSlug} base=${baseBranch} range=${baseSha.slice(0, 8)}..${headSha.slice(0, 8)}`,
);

const markdown = ensureCommitReportTimestamps(
runCodex({
// engine validated up front; codex is the default, --engine claude drives a fixed
// `claude` CLI through the same bounded runner (provider-neutral, zero new deps).
let reviewMarkdown: string;
if (engine === "claude") {
reviewMarkdown = runClaudeReview({
targetDir,
targetRepo,
sha: headSha,
baseSha,
metadata,
timeoutMs: argNumber(args, "claude_timeout_ms", 1_800_000),
workDir: runDir,
additionalPrompt,
});
} else {
reviewMarkdown = runCodex({
targetDir,
targetRepo,
sha: headSha,
Expand All @@ -498,9 +622,9 @@ function localReviewCommand(args: Args): void {
workDir: runDir,
additionalPrompt,
extraCodexConfig: [LOCAL_REVIEW_WEB_SEARCH_CONFIG],
}),
metadata,
);
});
}
const markdown = ensureCommitReportTimestamps(reviewMarkdown, metadata);

const outputPath = join(runDir, "local-review.md");
writeFileSync(outputPath, markdown.endsWith("\n") ? markdown : `${markdown}\n`, "utf8");
Expand Down
22 changes: 22 additions & 0 deletions test/local-review.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,28 @@ test("local-review rejects repositories covered only by a generic owner fallback
}
});

test("local-review rejects an unknown --engine", () => {
const dir = initRepo();
try {
const base = git(dir, "rev-parse", "HEAD");
writeFileSync(join(dir, "b.txt"), "2\n");
git(dir, "add", "b.txt");
git(dir, "commit", "-q", "-m", "second");
const { status, out } = runLocalReview(dir, [
"--target-repo",
"openclaw/clawsweeper",
"--base",
base,
"--engine",
"bogus",
]);
assert.equal(status, 1);
assert.match(out, /--engine must be "codex" or "claude"/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("local-review reports nothing to review when HEAD has no commits beyond base", () => {
const dir = initRepo();
try {
Expand Down
Loading