关于 review-deep
review-deep 是面向团队和维护者的代码审查技能,可对仓库进行广泛的分组件审查。它让九种审查视角分别使用三种模型,并并行运行独立的覆盖扫描;审查过程与实际仓库隔离。
主要功能
- 01进行 27 组视角与模型审查
- 02将仓库划分为最多 25 个文件的组件
- 03运行独立的 ocr 覆盖扫描
- 04审查者使用隔离的提交副本
- 05记录发现项与文件覆盖情况
适用场景
- 01从安全、架构、测试等方面审查仓库
- 02按限定文件组件检查大型代码库
- 03比较不同模型提供方的审查结果
- 04避免模型访问实际工作树
MCP 相关
审查流程仅使用 Serena 作为 MCP 服务,并配置它导航导出的审查项目。该技能不会加载操作者的其他 MCP 服务。
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ jimweller/ clanker-code-review-plugin --skill review-deep安装后在 Claude Code 中输入/review-deep确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
opencode/serena/reviewer-context.ymlsystem-prompt.mdprompts/collate.txtmerge.txtverify.txtscripts/check_reviews.pycheckout.pycollate.pycommon.pyevidence.pyexport.pyinit_run.pyledger.pymerge.pynormalize.pyopencode_env.pypartition.pypool.pypreflight.pyrank.pyreport.pyverify.pywindows.py详细介绍
STARTER_CHARACTER = 🕵️♂️
代码审查命令
从 9 个视角,使用 3 个模型审查代码库。仓库会拆分为最多包含 25 个文件的组件;27 个视角与模型的组合会分别审查每个组件,因此每个文件都会得到全部 27 项审查。每个审查分支都是独立的 opencode run 进程,在被审查提交的独立副本中工作,使用 Serena 浏览代码,并写入各自的发现。随后,每个组合都会进行一次全面检查,查找被组件边界掩盖的缺陷。
这里不会打包任务,也没有编排进程或 task 扇出。审查者会读取被审查提交中的文件,因此引用指向真实文件中的真实行。没有审查者会读取实时仓库。
同时还会运行一次 ocr scan。它会在独立对话中使用通用检查清单审查每个可审查文件,作为独立的第四个来源。
参数
如果用户在调用时提供了路径,则将其视为相对于仓库根目录的目标目录。否则审查整个仓库。这是有意设定的默认值,调用此技能的人已经接受,无需再次确认。
此技能以 context: fork 运行,并作为子 Agent 派发;没有用户在场回答提示词。第 2 步之前绝不要暂停,以确认范围、成本或模型费用。请从第 1 步直接进入派发。如果成本很重要,调用前就应通过传入更窄的路径来决定。
模型
| 标签 | 模型 ID | 变体 |
|---|---|---|
| openai | openai/gpt-6-sol | |
| gemini | google/gemini-3.8-flash | |
| claude | az-anthropic/claude-opus-5-5 | xhigh |
OCR 覆盖检查会使用 ~/.opencodereview/config.json 中的提供商和模型。
每个 ID 都必须存在于 ~/.config/opencode/opencode.json 中对应的提供商下,并列入该提供商的 whitelist。任一处缺少 ID,都会导致对应审查分支报错 Model not found,且此错误只会显示为 NDJSON 中的 error 事件。
jq -r '.provider | to_entries[] | .key as $p | .value.models | keys[] | "\($p)/\(.)"' ~/.config/opencode/opencode.json
gemini 审查分支有意使用 Flash 模型。Google 没有发布高于 gemini-3.1-pro-preview 的 Pro 模型,因此最新的 Google 模型是 gemini-3.8-flash,比最新的 Pro 模型高出七个次要版本。在一个包含 1070 个文件的仓库上进行的单次安全审查中,同一个 Agent 使用相同提示词时,Flash 给出了 11 项发现,而 Pro 给出了 6 项,使用的令牌数约为其两倍。这只是一次比较,并非基准测试。
学会这一规则花了两天
每次运行 opencode run 时,都要将标准输入重定向到 /dev/null。否则,进程会在创建会话前卡住,写入 0 字节,日志停留在 message=init,只有被终止时才会退出。opencode run 会先读取标准输入,直到 EOF,然后才执行其他操作,因此任何让标准输入保持打开状态的启动器都会让它挂起,后台作业、CI 步骤和 Agent 执行框架也都如此。这是 anomalyco/opencode issue #38723,针对 1.18.25 提出,并在此处使用 1.18.20 复现。
这种挂起看起来时有时无,但事实并非如此。启动器是否关闭标准输入决定了结果。三位报告者在 macOS arm64、Linux aarch64 和 Windows 上进行了确定性测试:不重定向时,5 次测试中 5 次挂起;重定向时,10 次测试中有 5 到 10 次成功。
本文件的早期版本禁止用 timeout 包装 opencode run,并将 44% 的失败率归咎于包装器。这是错误的。即使完全不使用 timeout,也能复现卡顿,而且上游自己的复现脚本使用了 timeout 120。移除所有超时限制,才让一个标准输入的小问题演变成持续数日的调查,因此现在 Step 2b 会检查每位审核者是否都有会话记录。
不要对审核者设置令牌上限或轮次上限。审核大型代码库需要多久就多久。
机械臂唯一可用的 MCP 服务是 Serena。Step 1 的 opencode_env.py 会使用 --project-from-cwd 启动它,而 --dir 会指向目标目录,以便 Serena 遍历并找到项目。Serena 从 SERENA_HOME 读取设置,该变量默认指向 ~/.serena-reviewer,其中存放着 serena_config.yml 和下载的语言服务器。
为什么禁用 researcher
机械臂不会加载操作员的 MCP 服务,因此 researcher 无法访问这些服务。了解它为何曾导致故障也很有价值:Google 会拒绝它的架构。
researcher 将可选数组声明为联合类型 ["null","array"],这符合 JSON Schema。Anthropic 和 OpenAI 都接受这种写法。Google 会将 JSON Schema 转换为自己的 OpenAPI 子集 proto,把联合类型转换为 any_of,从数组分支中移除 items,然后又因 any_of[0].items: missing field 拒绝自己的输出。gemini 机械臂会在 5 秒内失败,只产生一个 error 事件和 5712 字节的 NDJSON。
任何使用这种模式的 MCP 服务都会以同样的方式导致 Google 机械臂故障。如果 gemini 机械臂开始因 field predicate failed: $type == Type.ARRAY 而失败,请找到拥有相关命名属性的服务并禁用它。
操作流程
步骤 1:确定目标并做好准备
PROJECT_ROOT=$(git rev-parse --show-toplevel)
PROJECT_ROOT=$(cd -P "$PROJECT_ROOT" && pwd -P)
TARGET_PATH="<user-provided path or empty>"
[ -z "$TARGET_PATH" ] && TARGET_PATH="$PROJECT_ROOT"
TARGET_PATH=$(cd -P "$TARGET_PATH" 2>/dev/null && pwd -P) || { echo "TARGET_PATH does not exist"; exit 1; }
case "$TARGET_PATH" in
"$PROJECT_ROOT"|"$PROJECT_ROOT"/*) ;;
*) echo "TARGET_PATH escapes PROJECT_ROOT"; exit 1 ;;
esac
TARGET_NAME=$(basename "$TARGET_PATH")
[ "$TARGET_PATH" = "$PROJECT_ROOT" ] && TARGET_NAME="repo"
S="${CLAUDE_SKILL_DIR}/scripts" # replace ${CLAUDE_SKILL_DIR} with this SKILL.md's own directory on a non-Claude harness
[ -f "$S/init_run.py" ] || { echo "scripts not found at $S"; exit 1; }
python3 "$S/preflight.py" --tier deep || exit 1
STATE_DIR="$PROJECT_ROOT/.llmtmp/review-deep"
mkdir -p "$STATE_DIR"
find "$STATE_DIR" -mindepth 1 -delete
mkdir -p "$STATE_DIR/components" "$STATE_DIR/coverage" "$STATE_DIR/parts"
INIT=$(python3 "$S/init_run.py" "$PROJECT_ROOT" "$TARGET_PATH" "$STATE_DIR") || exit 1
echo "$INIT"
RUN_DIR=$(printf '%s\n' "$INIT" | sed -n 's/^RUN_DIR=//p')
EXPORT=$(python3 "$S/export.py" "$RUN_DIR" | sed -n 's/^EXPORT=//p')
[ -d "$EXPORT" ] || { echo "export.py made no export"; exit 1; }
SCOPE=""
[ "$TARGET_PATH" != "$PROJECT_ROOT" ] && SCOPE="--path ${TARGET_PATH#"$PROJECT_ROOT"/}"
ocr scan --preview --format json --repo "$EXPORT" $SCOPE 2>/dev/null > "$STATE_DIR/ocr-preview.json"
jq -r '.files[] | select(.will_review) | .path' "$STATE_DIR/ocr-preview.json" > "$STATE_DIR/ledger.txt"
jq -r '.files[] | select(.will_review | not) | "\(.path)\t\(.exclude_reason)"' "$STATE_DIR/ocr-preview.json" > "$STATE_DIR/skipped.txt"
python3 "$S/partition.py" "$STATE_DIR"
python3 "$S/opencode_env.py" "$STATE_DIR" || exit 1
echo "PROJECT_ROOT=$PROJECT_ROOT"
echo "TARGET_PATH=$TARGET_PATH"
echo "TARGET_NAME=$TARGET_NAME"
echo "STATE_DIR=$STATE_DIR"
echo "EXPORT=$EXPORT"
echo "LEDGER=$(wc -l < "$STATE_DIR/ledger.txt" | tr -d ' ') reviewable, $(wc -l < "$STATE_DIR/skipped.txt" | tr -d ' ') skipped"
preflight.py --tier deep 会在任何内容被清除之前,检查此技能所需的每个工具、配置文件、模型白名单条目和提供商密钥;如果检查失败,会直接指出具体名称,而不会等到运行中途才报错。$S 守卫会以同样的方式捕获不完整或损坏的插件安装。
init_run.py 会记录已审核的提交、此刻的 HEAD,并在仓库外的 ${XDG_CACHE_HOME:-~/.cache}/review-deep/<repo>-<commit12> 创建运行目录。它会打印 RUN_DIR,步骤 5 会用到它。如果工作树中有未提交的更改,它会发出警告,因为每位审查者和裁决者读取的都是该提交,因此未提交的更改不会经过审查。如果警告列出了源文件,请先提交更改。运行目录中的 run.json 保存模型、并发数、裁决者不得查看的文件,以及工单中携带的提交说明。要更改其中任何内容,请在步骤 5 之前编辑该文件。
export.py 会使用 git archive 将已审核的提交导出到一个新的临时目录,在任意深度删除 run.json 的 judge_exclude 中列出的所有名称,例如 CLAUDE.md、AGENTS.md、.claude/、.opencode/ 和 .llmdocs/,然后将结果提交为独立的单提交仓库,以便 opencode、Serena 和 ocr 能在其中找到项目根目录。每个工作分支都会审查各自的 cp -R 副本,ocr 扫描则会审查另一份副本。模型绝不会读取或修改正在使用的仓库。在一个包含 1179 个文件的仓库中测得,导出耗时 0.6 秒,占用 12 MB,因此 27 个并发副本总共约占 324 MB。
ocr scan --preview 会在导出目录上运行,列出每个文件,但不会调用模型,因此清单与各工作分支可见的内容完全一致。ledger.txt 保存可审查的文件,skipped.txt 保存其余文件及 ocr 给出的原因,例如 binary 或 unsupported_ext;步骤 5 会将其报告为跳过清单。
partition.py 会沿目录边界将清单划分为若干部分,每部分最多 25 个文件;这是 Anthropic 的 Claude Security 扫描器采用的规模。获得整个仓库的审查者会自行决定在审查 40 到 50 个文件后停止。获得某个部分的审查者则会审查其中的每个文件。包含 41 个文件的仓库会得到 1 个部分,包含 414 个文件的仓库会得到 22 个部分。
opencode_env.py 会写入工作分支唯一读取的 opencode 配置 $STATE_DIR/opencode-config/opencode/opencode.json。它会从 ~/.config/opencode/opencode.json 复制 provider 和 enabled_providers,并将 Serena 添加为唯一的 MCP 服务,同时将此插件的审查者上下文和手册放在 opencode/serena/ 下。它还会添加 9 个主要 Agent,其提示词内容取自插件的 agents/reviewer-<area>.md 正文,与 review-full 和 review-diff 提供给 Claude 审查者的文本相同。每个 Agent 都可以读取、搜索、使用 Serena 的读取工具,并且只能在 $STATE_DIR/parts 和 $STATE_DIR/coverage 下写入。没有技能、子 Agent、shell 或 Web 工具。
使用 find -delete,不要使用 rm -f。如果 PATH 中有 safe-rm 替代程序,它会将路径移入废纸篓,并且即使使用 -f,遇到不存在的路径也会以非零状态退出,导致首次运行时的清理失败。
步骤 2:派发组件审查者并执行覆盖率检查
27 个领域与模型的组合会分别审查每个组件,因此每个组件的运行都会有 27 个工作分支。工作池会一次运行 CONCURRENCY 个任务,同时进行一次 ocr 扫描。请替换为步骤 1 打印的值。
请按原样将此代码块作为一次后台 Bash 调用运行。该调用会一直保持打开状态,直到工作池和 ocr 扫描退出;完成通知就是步骤 2 已结束的信号。绝不要使用 nohup、末尾带 & 的命令,或在后台启动的包装脚本来运行此代码块,否则调用会立即返回,之后也不会收到通知。绝不要通过日志中的 done, 行来判断是否完成。在一次 quartermaster 运行中,Agent 将代码块分离运行,并使用 tail -f | grep -m1 '^done,' 等待。grep 匹配后退出,但 tail 只有在下一次写入时才会退出,而 done, 是日志的最后一行。调用因此一直闲置,直到 60 分钟的 timeout 结束进程并杀死 tail。
export PROJECT_ROOT TARGET_PATH STATE_DIR EXPORT
AREAS="security architecture solid correctness testing ops performance quality data"
MODELS="openai:openai/gpt-6-sol gemini:google/gemini-3.8-flash claude:az-anthropic/claude-opus-5-5"
CONCURRENCY=27
cat > "$STATE_DIR/arm.sh" <<'ARM'
#!/bin/bash
label=$1 model=$2 area=$3 comp=$4
if [ ! -d "$STATE_DIR/parts" ] || [ ! -d "$TARGET_PATH" ] || [ ! -d "$PROJECT_ROOT" ] || [ ! -d "$EXPORT" ]; then
echo "arm.sh: STATE_DIR='$STATE_DIR' TARGET_PATH='$TARGET_PATH' PROJECT_ROOT='$PROJECT_ROOT' EXPORT='$EXPORT'. Export all four with the values Step 1 printed." >&2
exit 255
fi
VARIANT=""
[ "$label" = claude ] && VARIANT="--variant xhigh"
base="$label-$area-$comp"
arm_dir=$(mktemp -d "${TMPDIR:-/tmp}/review-deep-arm.XXXXXX")
trap 'rm -rf "$arm_dir"' EXIT
copy="$(cd -P "$arm_dir" && pwd -P)/repo"
cp -R "$EXPORT" "$copy"
work="$copy/.review-arm"
mkdir -p "$work" "$STATE_DIR/arms"
echo "$copy" > "$STATE_DIR/arms/$base.root"
target="$copy${TARGET_PATH#"$PROJECT_ROOT"}"
if [ "$comp" = sweep ]; then
cp "$STATE_DIR/ledger.txt" "$work/ledger.txt"
cp "$STATE_DIR/$label-$area.md" "$work/listed-findings.md"
scope="LEDGER_PATH: $work/ledger.txt
FINDINGS_PATH: $work/listed-findings.md
LEDGER_PATH lists every reviewable source file. A component-by-component
review already covered them, and its findings are in FINDINGS_PATH. Look only
for defects that are not already listed there, especially defects that span
components. Do not repeat a listed finding."
else
cp "$STATE_DIR/components/$comp.txt" "$work/ledger.txt"
scope="LEDGER_PATH: $work/ledger.txt
COVERAGE_PATH: $work/coverage.txt
LEDGER_PATH lists the source files assigned to you, one path per line,
relative to $copy. Read it first and review every file it lists. The rest of
the repository is context: follow calls into it, and report defects in the
listed files.
For every path in LEDGER_PATH, write one line to COVERAGE_PATH: the path, then
reviewed or skipped, then a short reason. Every listed path must appear
exactly once before you finish."
fi
prompt="Review the source files under $target that your ledger assigns.
Navigate with serena symbolic tools: get_symbols_overview to map a file,
find_symbol to read a definition, find_referencing_symbols to find callers,
find_declaration and find_implementations to resolve a usage. Read a whole
file only when symbolic navigation cannot answer the question. Everything
you need is under $copy.
Cite path:line with the path relative to $copy, and name the enclosing
function, method, or type. A line you saw counts as verified, and find_symbol
returns a symbol's line range. When you cannot verify the exact line, cite the
nearest line you saw and end the finding with \`line unconfirmed\`. Never drop
a real defect because its line number is uncertain.
$scope
OUTPUT_PATH: $work/findings.md
Write your findings to OUTPUT_PATH. Writing that file is mandatory and is
how your work is delivered. Do not return findings as your response."
raw="$STATE_DIR/parts/raw-$base.ndjson"
wrote_findings() {
[ -s "$work/findings.md" ] ||
jq -r 'select(.type=="text") | .part.text' "$raw" 2>/dev/null | grep -q '^## '
}
mkdir -p "$STATE_DIR/opencode-db" "$STATE_DIR/parts/attempts"
attempt=1
max_attempts=3
while :; do
XDG_CONFIG_HOME="$STATE_DIR/opencode-config" \
OPENCODE_PURE=1 \
OPENCODE_DISABLE_EXTERNAL_SKILLS=1 \
OPENCODE_DISABLE_CLAUDE_CODE=1 \
OPENCODE_DISABLE_PROJECT_CONFIG=1 \
OPENCODE_DISABLE_AUTOUPDATE=1 \
OPENCODE_DB="$STATE_DIR/opencode-db/$base.db" \
opencode run \
--agent "reviewer-$area" \
-m "$model" $VARIANT \
--format json \
--dir "$target" \
--title "Review $label $area $comp" \
"$prompt" \
< /dev/null \
> "$raw" 2> "$STATE_DIR/parts/$base.log"
if wrote_findings && ! grep -q '"Failed to execute statement"' "$raw" 2>/dev/null; then break; fi
[ "$attempt" -ge "$max_attempts" ] && break
mv "$raw" "$STATE_DIR/parts/attempts/raw-$base.attempt$attempt.ndjson"
sleep $((5 + RANDOM % 15))
attempt=$((attempt + 1))
done
[ -s "$work/findings.md" ] && sed "s|$copy/||g" "$work/findings.md" > "$STATE_DIR/parts/$base.md"
[ -s "$work/coverage.txt" ] && sed "s|$copy/||g" "$work/coverage.txt" > "$STATE_DIR/coverage/$base.txt"
wrote_findings || { echo "arm.sh: $base wrote no findings in $attempt attempts" >&2; exit 1; }
ARM
: > "$STATE_DIR/tasks.txt"
for comp in $(ls "$STATE_DIR/components" | sed 's/\.txt$//'); do
for entry in $MODELS; do
for area in $AREAS; do
echo "${entry%%:*} ${entry#*:} $area $comp" >> "$STATE_DIR/tasks.txt"
done
done
done
SCOPE=""
[ "$TARGET_PATH" != "$PROJECT_ROOT" ] && SCOPE="--path ${TARGET_PATH#"$PROJECT_ROOT"/}"
OCR_COPY="$(mktemp -d "${TMPDIR:-/tmp}/review-deep-ocr.XXXXXX")/repo"
cp -R "$EXPORT" "$OCR_COPY"
ocr scan --audience agent --repo "$OCR_COPY" $SCOPE --max-tokens 1000000 \
--format json --output "$STATE_DIR/ocr-scan.json" \
< /dev/null > /dev/null 2> "$STATE_DIR/ocr-scan.log" &
echo $! > "$STATE_DIR/ocr.pid"
echo "dispatching $(wc -l < "$STATE_DIR/tasks.txt" | tr -d ' ') arms, $CONCURRENCY at a time, plus 1 ocr scan"
xargs -P "$CONCURRENCY" -L 1 bash "$STATE_DIR/arm.sh" < "$STATE_DIR/tasks.txt"
wait
rm -rf "$(dirname "$OCR_COPY")"
echo "done, $(ls "$STATE_DIR"/parts/*.md 2>/dev/null | wc -l | tr -d ' ') part files written"
xargs -P 会在 tasks.txt 的扁平任务列表上滚动调度任务,而不是按组件分批同步执行:27 个槽位中只要有一个空出,列表中的下一个任务就会启动,不论它属于哪个组件。c01 的任务仍在运行时,c03 的任务通常就会启动。不要报告批次数量或逐个组件的预计耗时;应根据实际吞吐量来衡量,即每分钟实际经过时间内写入的 parts/*.md 数量,并据此推算剩余时间。
CONCURRENCY=27 会同时运行 27 个 opencode 进程,一台 48 GB 的机器可以轻松承载。每个进程占用 370 到 800 MB。
审查器以独立设备模式运行。XDG_CONFIG_HOME 会让 opencode 使用 Step 1 生成的配置,因此不会加载操作人员的 opencode Agent、技能、规则、插件或 MCP 服务。凭据仍可正常解析,因为 opencode 会将 auth.json 保存在 XDG_DATA_HOME 下,而服务提供商会从环境变量读取密钥。OPENCODE_PURE=1 会跳过外部插件,OPENCODE_DISABLE_EXTERNAL_SKILLS=1 会跳过对 ~/.claude 和 ~/.agents 的技能扫描,OPENCODE_DISABLE_CLAUDE_CODE=1 会跳过 ~/.claude/CLAUDE.md 和 .claude/skills,而 OPENCODE_DISABLE_PROJECT_CONFIG=1 会跳过被审查仓库的 opencode.json、.opencode/ 和 AGENTS.md。我们使用 opencode 1.18.20,通过 opencode debug config、opencode debug skill 和一个要求模型找出预先埋入的指令,对每项设置进行了测试。
每个任务臂都会在导出内容的副本 $TMPDIR/review-deep-arm.* 中运行,并将 --dir 指向该副本内的目录。arm.sh 会将任务臂的账本,以及在一次扫描中该区域合并后的发现项,放入副本的 .review-arm/。任务臂会在此处使用 write 写入发现项和覆盖记录;如果使用的是 OpenAI 模型,则通过 apply_patch 写入。arm.sh 会将这些文件复制到 parts/ 和 coverage/,从引用中移除副本的路径,并在退出时删除副本。生成的 Agent 会先拒绝所有工具,然后允许使用 read、grep、glob、写入工具,以及 Serena 中除四类写入操作外的工具。一个共享的 external_directory 规则会拒绝访问任务臂自身副本之外的所有路径,而 edit 规则只允许在 .review-arm/ 下写入。我们在 opencode 1.18.20 上进行了测试:对副本外的绝对路径使用 grep、使用 glob 和 read 均会被拒绝,而在副本内使用 grep 则可正常工作。
Serena 作为独立进程运行,因此 opencode 的规则无法约束它。我们在 Serena 1.7.0 上进行了测试:它会拒绝离开副本的相对路径和绝对目录路径,但对副本外文件的绝对路径调用 find_symbol、get_symbols_overview 和 get_diagnostics_for_file 时,仍会返回结果。知道或猜到机器上其他位置的文件路径的任务臂,仍可通过 Serena 读取该文件中的符号。它能解析副本内的 go.work 模块,即使该模块跨越了模块边界。它们以 mode: primary 运行,因为通过 --agent 选择 Agent 时,opencode 会忽略子 Agent 的 tools 块。
审查器不得调用 task。在一个包含 1070 个文件的仓库中,27 个审查器里有 2 个启动了一个始终未返回的子 Agent;由于 TaskTool 会以前台方式运行子 Agent,并在 yield 处阻塞,父进程会卡住,Step 2 的 wait 也会因此卡住。这些 Agent 设置了 tools.task: false 和 permission.task: {"*": deny}。修复后,两名审查器均顺利完成,task 调用次数为 0。
目前没有任何机制可防止同时运行两个任务。Step 1 会清空 STATE_DIR,而每个任务臂都会写入固定路径,因此对同一仓库再次运行会破坏第一次运行的结果。调度任务前,请先检查是否已有任务正在运行。
服务提供商的限额并不是瓶颈。OpenAI 报告的每分钟令牌数为 40,000,000,Azure Foundry 则为 15,000,000。
opencode 自身的会话存储在 CONCURRENCY=27 时会因写入压力而崩溃。在一个包含 1217 个文件的仓库上测得:26 个 arm 在四秒内集中失败,错误为 error="Failed to execute statement",问题出在 opencode 共享的 SQLite 存储中(可在 ~/.local/share/opencode/log/opencode.log 中看到,而 arm 自身的 stderr 中看不到,后者始终为空)。其中 25 个完全没有写入输出。已在上游确认(anomalyco/opencode#48416、#47566、#38849、#33320、#21215):opencode 的 SQLite 连接使用 busy_timeout=0,因此争抢写锁失败的写入者会立即报错,而不会等待;SQL 适配器上的 Effect.orDie 会将其转成致命消息错误,而不是警告。失败的写入会在任意并发进程的工具调用返回后,立即从 child_process 退出处理程序中触发,因此它取决于当时有多少 opencode 进程处于运行且繁忙状态,而不是刚刚启动了哪些进程。
arm.sh 为每个 arm 使用独立数据库,不再争用共享数据库:OPENCODE_DB="$STATE_DIR/opencode-db/$base.db"(通过阅读 opencode 自身的源代码确认:它会直接检查 OPENCODE_DB;若给定绝对路径,就将其用作 SQLite 文件,不会再相对于共享数据目录解析)。这是真正的修复,而非变通方案;这种规模的运行不再访问 ~/.local/share/opencode/opencode.db,写入争用失败也就无从发生。auth.json、提供方凭据以及 bin/ 下下载的工具缓存都不会受到影响,因为只有数据库路径发生变化。
在两种情况下,重试循环会以相同的 opencode run 调用最多再运行两次。第一种情况是 arm 的 NDJSON 中匹配到 "Failed to execute statement",这是为每个 arm 独立数据库仍未覆盖的情况提供纵深防御。第二种情况是 arm 结束时没有发现结果,也就是说 OUTPUT_PATH 为空,且没有任何文本事件包含 ## 标题。在一个包含 718 个文件的仓库上测得:351 个 gemini arm 中有 20 个读取了文件,没有进行写入调用,并以 0 个输出 token 的 stop 步骤结束。写入了发现结果的 331 个 gemini arm 中,没有一个以这种方式结束。每次尝试之间会进行 5 至 19 秒的抖动休眠,避免重试再次集中到同一个争用时段。被后续尝试取代的尝试,其 NDJSON 会移动到 parts/attempts/raw-$base.attemptN.ndjson,且永不删除。该目录位于 Step 3 提取的 parts/raw-*.ndjson glob 匹配范围之外。若 arm 重试 3 次后仍没有发现结果,则会以 1 退出,在 stderr 中输出其名称,并在 Step 3 中显示为普通的 MISSING 条目。
如果 STATE_DIR、TARGET_PATH、PROJECT_ROOT 或 EXPORT 未设置或指定的目录不存在,arm.sh 会以 255 退出。xargs 在遇到 255 退出码后会停止启动 arm,因此,从未导出这些变量的 shell 中分派批次,会导致其在启动最初几个 arm 后失败,而不是报告成功。Shell 变量不会在 Bash 调用之间保留,因此每次运行 arm.sh 的调用都要导出全部四个变量。有一个重试批次在 STATE_DIR 为空的情况下运行了全部 20 个 arm。每个 arm 都因 mkdir: /opencode-db: Read-only file system 而失败,但批次仍以 0 退出。
步骤 2b:确认执行单元已启动
在步骤 2 仍在等待时,单独以前台调用的方式运行此命令。未能创建会话的执行单元不会写入任何内容,也永远不会写入,并且会占用一个池槽位。
find "$STATE_DIR/parts" -maxdepth 1 -name 'raw-*.ndjson' -size 0 -mmin +1 | sed 's|.*/raw-||; s|\.ndjson$||; s/^/NO SESSION /'
正常的执行单元会在启动后几秒内写入 NDJSON。终止此处列出的所有执行单元。步骤 3 会重新派发它们。
使用步骤 2 保存的 PID,以相同方式检查 ocr 扫描。它以 node 运行,而不是 ocr,因此 pgrep -x ocr 或匹配 ocr scan 名称都找不到它;有一次,进程检查没有结果时,误报为“ocr 扫描已退出”,但它实际上仍在运行。
OCR_PID=$(cat "$STATE_DIR/ocr.pid" 2>/dev/null)
if [ -n "$OCR_PID" ] && kill -0 "$OCR_PID" 2>/dev/null; then
echo "ocr scan running, pid $OCR_PID, log last wrote $(stat -f '%Sm' "$STATE_DIR/ocr-scan.log" 2>/dev/null)"
else
echo "ocr scan not running and $STATE_DIR/ocr-scan.json is $([ -s "$STATE_DIR/ocr-scan.json" ] && echo present || echo MISSING)"
fi
几分钟没有新增输出的旧日志并不能证明进程卡住了;该工具只会记录警告和失败的工具调用,因此成功工作期间没有日志是正常的。在一个包含 1217 个文件的仓库中测得:日志安静了约 40 分钟,之后没有再写入任何内容,并在启动约 80 分钟后干净退出,且存在 ocr-scan.json,状态为 status=success(已审查 719 个文件,发现 2586 个问题)。对于这种规模的仓库,数十分钟没有日志是正常运行的预期表现,并非异常症状。进程不在 ps 中(kill -0 失败)且缺少 ocr-scan.json,才能证明它已停止。仅在这种情况下,才使用步骤 2 中的相同命令重新启动,并使用相同的 $STATE_DIR/ocr-scan.json 路径。
步骤 3:收集并验证
有时,执行单元会将发现作为响应返回,而不是写入 OUTPUT_PATH。gpt-6-sol 就是如此。发现内容位于 NDJSON 中,因此请先将它们复制出来。没有可复制内容的执行单元不会留下部件文件,因此 MISSING 检查和文件计数会一致。然后将每个区域和模型的部件合并为一个文件。
合并操作会从各个组成部件重新构建每个合并文件,并且在步骤 4 运行后,还会加入该区域的扫描部件。无论何时再次运行,结果都相同,因此在扫描后重新派发不会丢失扫描结果。
for f in "$STATE_DIR"/parts/raw-*.ndjson; do
b=$(basename "$f" .ndjson); out="$STATE_DIR/parts/${b#raw-}.md"
[ -s "$out" ] && continue
jq -r 'select(.type=="text") | .part.text' "$f" | awk '/^## /{p=1} p' | sed -E 's#[^ `]*/review-deep-arm\.[^/ ]+/repo/##g' > "$out"
[ -s "$out" ] || rm -f "$out"
done
for label in openai gemini claude; do
for area in security architecture solid correctness testing ops performance quality data; do
out="$STATE_DIR/$label-$area.md"
parts=$(ls "$STATE_DIR"/parts/"$label-$area"-c*.md "$STATE_DIR"/parts/"$label-$area"-sweep.md 2>/dev/null)
[ -z "$parts" ] && continue
{ grep -h -m1 '^## ' $parts | head -1; echo; grep -h '^- \*\*' $parts; } > "$out"
grep -q '^- \*\*' "$out" || echo "No findings." >> "$out"
for c in "$STATE_DIR"/components/*.txt; do
comp=$(basename "$c" .txt)
[ -s "$STATE_DIR/parts/$label-$area-$comp.md" ] || echo "MISSING $label $area $comp"
done
done
done
for label in openai gemini claude; do
echo "== $label =="
for area in security architecture solid correctness testing ops performance quality data; do
f="$STATE_DIR/$label-$area.md"
if [ ! -s "$f" ]; then
echo " $area MISSING"
else
n=$(grep -c '^- \*\*' "$f")
cited=$(grep -oE '^- \*\*(Critical|High|Medium|Low)\*\* `[^`]+`' "$f" | grep -c ':[0-9]')
unconf=$(grep -c 'line unconfirmed' "$f")
echo " $area ok findings=$n cited=$cited unconfirmed=$unconf"
fi
done
done
然后检查覆盖情况。每个执行单元的覆盖记录会说明它审查了哪些文件。已打开文件数会根据执行单元实际读取的内容检查这些记录。
total=$(wc -l < "$STATE_DIR/ledger.txt" | tr -d ' ')
while read -r label model area comp; do
f="$STATE_DIR/coverage/$label-$area-$comp.txt"
want=$(wc -l < "$STATE_DIR/components/$comp.txt" | tr -d ' ')
got=$(awk '$2 ~ /^reviewed/' "$f" 2>/dev/null | wc -l | tr -d ' ')
[ "$got" -lt "$want" ] && echo "SHORT $label $area $comp reviewed=$got of $want"
done < "$STATE_DIR/tasks.txt"
cat "$STATE_DIR"/coverage/*.txt 2>/dev/null | awk '$2 ~ /^reviewed/ {print $1}' | sort -u > "$STATE_DIR/reviewed.txt"
echo "ledger files reviewed by at least one arm=$(comm -12 <(sort "$STATE_DIR/ledger.txt") "$STATE_DIR/reviewed.txt" | wc -l | tr -d ' ') of $total"
comm -23 <(sort "$STATE_DIR/ledger.txt") "$STATE_DIR/reviewed.txt" | sed 's/^/ never reviewed: /'
jq -r 'select(.type=="tool_use") | .part.state.input | (.filePath // .relative_path // .path // empty)' \
"$STATE_DIR"/parts/raw-*.ndjson 2>/dev/null | sed -E 's#^.*/review-deep-arm\.[^/]+/repo/##' | sort -u > "$STATE_DIR/opened.txt"
echo "ledger files opened=$(comm -12 <(sort "$STATE_DIR/ledger.txt") "$STATE_DIR/opened.txt" | wc -l | tr -d ' ') of $total"
if [ -s "$STATE_DIR/ocr-scan.json" ]; then
jq -r '"ocr status=\(.status) files=\(.summary.files_reviewed) findings=\(.comments|length)"' "$STATE_DIR/ocr-scan.json"
else
echo "ocr-scan.json MISSING, run the Step 2b ocr liveness check before treating this as a dead scan"
fi
SHORT 行表示某个执行单元将其部分组成文件标记为 skipped,或未写入覆盖记录。请在覆盖记录中查看原因。因文件属于其他区域而跳过是可以接受的。因审查深度不足而跳过则是缺口。
从一个导出步骤 1 打印的值的 Shell 中,重新派发 MISSING 执行单元,然后再次运行上面的提取与合并代码块。arm.sh 已经重试过该执行单元两次,因此只需重新派发一次。
export PROJECT_ROOT=<PROJECT_ROOT> TARGET_PATH=<TARGET_PATH> STATE_DIR=<STATE_DIR> EXPORT=<EXPORT>
bash "$STATE_DIR/arm.sh" <label> <model> <area> <comp>
如果某个区域的合并文件缺失,或者既没有发现行,也没有且仅有 No findings.,则将该区域视为未审查。绝不要将这两种情况记录为 No findings.。一次重试后仍缺失的所有执行单元,都应报告给操作人员并标记为未审查,同时注明模型、区域和组成文件。
比较 cited 与 findings。如果某项发现的第一个反引号字段不含 :line,则该发现不可用,因为除非重新阅读整个文件,否则无人能够确认它。cited 会统计带有 path:line 的发现,无论其末尾是否标有 line unconfirmed;因此,审查者认可的数量应从中减去 unconfirmed。重新派发前,请先阅读未引用来源的发现;只有它们也没有符号时,才重新派发。
步骤 4:全面扫描
每个区域和模型各启动一个全面扫描分支。它会获取完整台账和该区域合并后的发现,只查找尚未列出的问题。这能发现组件边界掩盖的问题。
: > "$STATE_DIR/sweeps.txt"
for entry in $MODELS; do
for area in $AREAS; do
[ -s "$STATE_DIR/${entry%%:*}-$area.md" ] && echo "${entry%%:*} ${entry#*:} $area sweep" >> "$STATE_DIR/sweeps.txt"
done
done
xargs -P "$CONCURRENCY" -L 1 bash "$STATE_DIR/arm.sh" < "$STATE_DIR/sweeps.txt"
像步骤 2 一样,将此代码块作为一次后台 Bash 调用运行。完成通知就是继续操作的信号。
然后重新运行步骤 3 的提取与合并代码块。它会提取每次全面扫描的发现,并据此重建该区域的合并文件。之后检查每次全面扫描。
while read -r label model area comp; do
s="$STATE_DIR/parts/$label-$area-sweep.md"
if [ -s "$s" ]; then echo "$label-$area sweep added=$(grep -c '^- \*\*' "$s")"; else echo "MISSING $label $area sweep"; fi
done < "$STATE_DIR/sweeps.txt"
按照步骤 3 中 MISSING 分支的相同方式,重新派发 MISSING 全面扫描。
如果某个代码块作为独立调用运行,而非与步骤 2 一起运行,请重新导出 STATE_DIR、TARGET_PATH、PROJECT_ROOT 和 EXPORT,并重置 AREAS、MODELS 和 CONCURRENCY。
最后一次全面扫描和最后一次重新派发结束后,删除导出内容。步骤 5 会为评审员单独检出代码。
rm -rf "$EXPORT"
步骤 5:汇总、验证并报告
运行脚本化阶段。将每个模型阶段作为后台 Bash 调用运行。所有调用完成后,调用池才会返回,完成通知就是继续操作的信号。切勿使用 sleep 轮询正在运行的阶段。直接将阶段命令作为后台调用运行。切勿使用 nohup 或在命令末尾加上 & 来分离进程,也切勿使用 tail -f | grep -m1 等待日志行,原因见步骤 2。
S="${CLAUDE_SKILL_DIR}/scripts" # replace ${CLAUDE_SKILL_DIR} with this SKILL.md's own directory on a non-Claude harness
[ -f "$S/init_run.py" ] || { echo "scripts not found at $S"; exit 1; }
RUN=<RUN_DIR printed by Step 1>
python3 "$S/checkout.py" "$RUN"
python3 "$S/normalize.py" "$RUN"
python3 "$S/collate.py" "$RUN" # one model call per component, background
uv run --with scikit-learn --with scipy --with numpy python "$S/windows.py" "$RUN"
python3 "$S/merge.py" "$RUN" # one model call per similarity window, background
python3 "$S/rank.py" "$RUN"
python3 "$S/verify.py" "$RUN" # one model call per Critical or High issue, background
python3 "$S/report.py" "$RUN"
| 阶段 | 输出 | 功能 |
|---|---|---|
checkout.py | checkout/ | 使用 git archive 导出已评审的提交,并删除 judge_exclude 路径,例如 CLAUDE.md、.claude/ 和评审状态。每个模型阶段只读取此目录树。 |
normalize.py | findings.jsonl | 解析每个合并后的发现文件,并将 OCR 注释转换为带有稳定 ID 的发现。遇到任何无法解析的项目符号时,以状态码 1 退出。 |
collate.py | issues-by-component.jsonl | 将每个组件的发现归类为问题,其中一个问题对应一项代码更改。每个发现 ID 必须且只能归入一个问题或丢弃列表。 |
windows.py | windows.json | 按文本相似度对所有问题排序,并划分重叠窗口,每个窗口包含 150 个问题,因此同一窗口可以看到在两个组件中报告的缺陷。 |
merge.py | global_merges.json | 合并描述同一缺陷的问题,即一次更改即可全部修复的问题。独立位置中的相同模式仍分别保留。 |
rank.py | issues.jsonl、issues.md | 应用合并结果,并按严重程度、来源数和发现数排序。 |
verify.py | verify/results.jsonl、verify/second.jsonl | 使用高档 Opus 尝试反驳每个严重和高优先级问题,并返回判定、引用的证据、无法成立的位置和成员,以及其自身评定的严重程度。高档 Fable 会对验证者确认的每个严重问题进行盲审复核。 |
report.py | report.md | 汇总数量、按两次评级中较低评级列出每个已确认的问题、列出带有原因的反驳问题,以及覆盖缺口。 |
每次模型调用都会作为独立的 claude -p --bare 进程运行,使用 --tools Bash,Read 和 --permission-mode dontAsk,提示词缓存时间为 5 分钟,提示词通过标准输入传入。调用池会检查每个进程是否恰好以这些工具启动,检查每个回答的内容,并将拒绝回答的原因附加后最多重试三次。如果判定中的证据引用并非来自已评审提交中的代码,该回答会被拒绝。review-tickets 技能文档说明了这些设置背后的衡量依据。
合并规则比以前更严格。早期规则还会合并具有相同修复方式的同类缺陷。结果生成了一个包含 77 条发现、涉及 24 个文件的问题,而据此生成的工单引用的位置并不能支持该主张。
向操作人员报告 report.md 中的统计数量、已确认的严重问题以及覆盖缺口。运行目录是 review-tickets 技能的输入。
预期输出文件
.llmtmp/review-deep/ 保存整个运行过程,并会在下次运行开始时清空:
<label>-<area>.md,27 个合并后的发现文件,包含组件部分和全面检查ocr-scan.json、ocr-scan.log和ocr.pid,覆盖率检查ledger.txt和skipped.txt,分别记录可审查文件,以及附有 ocr 排除原因的文件components/cNN.txt,分区coverage/<label>-<area>-<comp>.txt,每个执行臂的已审查或已跳过记录reviewed.txt和opened.txt,分别记录执行臂标记为已审查的文件,以及它们打开过的文件parts/<label>-<area>-<comp>.md、parts/raw-*.ndjson、parts/*.log,分别记录每个执行臂的发现、事件流和标准错误parts/attempts/raw-*.attemptN.ndjson,被取代的尝试的事件流;当arm.sh在 SQLite 写入争用失败后重试,或某个执行臂未写入任何发现时保留opencode-db/<label>-<area>-<comp>.db,每个执行臂独立的 opencode 会话数据库opencode-config/,生成的 opencode 配置,供每个执行臂运行,并包含按区域划分的提示词文件arms/<label>-<area>-<comp>.root,每个执行臂运行所在副本的路径,也是其工具调用可能触及的唯一根目录arm.sh、tasks.txt、sweeps.txt,运行器及其工作列表
运行目录 ${XDG_CACHE_HOME:-~/.cache}/review-deep/<repo>-<commit12>/ 保存第 5 步的输出,并会在审查不同提交时继续保留:
run.json,记录仓库、提交、模型、并发数、评审排除项和提交说明checkout/,供每位评审者读取的导出内容,以及checkout_excluded.jsonfindings.jsonl、issues-by-component.jsonl、dropped.jsonl、unassigned.jsonl、windows.json、global_merges.jsonissues.jsonl和issues.md,包含每个问题及其内联的成员发现verify/results.jsonl和verify/second.jsonl,评审结果report.mdcollate/、merge/、verify/first/、verify/second/,每个目录均包含pool.log,以及每个模型进程位于calls/下的事件流
NDJSON 参考
每行一个 JSON 对象。跳过解析失败的行。
| 类型 | 含义 | 关键字段 |
|---|---|---|
step_start | 大模型回合开始 | |
text | 模型输出文本 | part.text |
tool_use | 模型调用工具 | part.tool、part.state.status、part.state.input |
step_finish | 回合完成 | part.reason、part.cost、part.tokens |
error | 会话级错误 | error.data.message |
step_finish 的原因是 stop 表示已完成,tool-calls 表示仍在继续。
jq -s '[.[] | select(.type=="step_finish") | .part.cost] | add' "$NDJSON"
tac "$NDJSON" | jq -s 'first(.[] | select(.type=="step_finish")) | .part.tokens.total'
jq -r 'select(.type=="tool_use") | .part.tool' "$NDJSON" | sort | uniq -c | sort -rn
jq -r 'select(.type=="error") | .error.data.message' "$NDJSON"
Serena 在一个 TypeScript 仓库中执行了 181 次工具调用中的 119 次;在一个包含 HTML、CSS 和 Dockerfile 的仓库中,则只执行了 28 次中的 2 次。对于没有语言服务器支持的文件,符号导航几乎没有价值。
测试
针对 review-deep 和 review-tickets 的测试与评估,以及覆盖全部四项技能的防护测试和预检测试,都位于此插件仓库根目录下的 evals/ 中,不属于已安装的技能。
| 检查项 | 命令(在 evals/ 中运行) | 成本 |
|---|---|---|
| 单元测试:每项技能各一个目录,另加预检和防护测试 | uv run --with pytest pytest -q unit/review_deep、unit/review_tickets、unit/preflight 和 unit/guard | 无 |
| 使用 harbor fixture 对全部三种类型进行端到端重放 | uv run --with pytest pytest -q test_end_to_end.py | 无 |
针对 fixture/truth.json 的实时质量评估 | python3 live_eval.py WORK_DIR --run | 按标价约 $3 |
unit/review_deep 会导入 windows.py,而该文件会导入 scikit-learn、scipy 和 numpy,因此请使用 uv run --with pytest --with numpy --with scipy --with scikit-learn pytest -q unit/review_deep 运行。其他目录无需额外的包。
每次修改 scripts/ 后,都要运行单元测试和重放。提示词发生变化时,重放会因 "prompt changed" 而停止。使用 python3 pipeline.py WORK_DIR --mode record 重新录制,使用 live_eval.py 检查运行结果,再用 python3 golden.py save RUN_DIR 保存。修改模型、effort level 或提示词后,按需运行实时评估。
规则
- 发起调用的 Agent 只负责启动,不执行任何评审分析。所有分析都由 Step 5 脚本及其模型调用完成。
- 绝 NEVER 让 Step 5 模型调用读取实时工作树。它们会在运行目录的
checkout/中运行。 - 绝 NEVER 使用
sleep、等待循环或tail -f | grep -m1之类的日志监视器轮询后台阶段。请等待其完成通知。 - 将提示词保存在
prompts/中,格式为.txt。Markdown 格式化工具曾重写.md提示词并改变其含义。 - 始终在
opencode run上将标准输入重定向自/dev/null。省略此项会导致进程在创建会话前挂起,没有输出也没有错误;这是 anomalyco/opencode issue #38723。 - 不要为评审者设置 token 上限或轮次上限。让它完成。
- 派发后约一分钟运行 Step 2b,长时间运行时也要再次运行。NDJSON 为空的 arm 已失效,却会占用池槽位;仅凭耗时无法区分这种情况和缓慢的评审。
- 始终将 Step 2 和 Step 4 的派发代码块作为后台 Bash 调用运行,绝不要使用
nohup或&将其脱离会话运行。池会阻塞,直到每个 arm 都退出;放到后台运行可避免阻塞主会话。调用的完成通知是池已完成的唯一信号。 - 每个运行
arm.sh的 Bash 调用都必须始终导出STATE_DIR、TARGET_PATH和PROJECT_ROOT,包括单次重新派发。Shell 变量不会跨调用保留;缺少这些变量时,arm.sh会以 255 退出。 - 在同一个 Bash 代码块中,通过池启动每个 arm,并运行 OCR 扫描。它们是彼此独立的进程。
- 使用普通消息调用,不要使用
--command。--command标志存在与 context7 MCP 服务配合使用时的已知问题。 - 运行期间不要清理各区域文件、NDJSON 或日志。Step 1 会在下次运行开始时清理它们。
- 如果某个 arm 失败,仍要等待其他 arm 完成并报告它们的结果。
- opencode arms 只需要操作员在
~/.config/opencode/opencode.json中配置的提供方,以及位于~/.serena-reviewer的 Serena 主目录。它们的 Agent、Serena 上下文和 Serena 手册都由此插件通过opencode_env.py提供。preflight.py --tier deep会在 Step 1 清理任何内容之前,按名称检查提供方和 Serena 主目录。