caveman 会话级模式状态测试计划:三层验证体系与 Claude Code Hook 状态机实践
本文基于 caveman 仓库的 会话模式测试计划 展开,讲解如何自证「per-session 模式状态补丁」真正生效:第一层用自动化测试套件钉死逻辑回归,第二层用端到端 smoke 脚本驱动真实 hook 二进制与文件系统,第三层在真实 Claude Code 进程中验证徽章、上下文压缩(compaction)与插件 hook 注册这类无法伪造的行为。读完后,你可以掌握一套「单元 → 集成 → 实机」的分层验证方法,并看懂 caveman 模式状态存储(off 作为持久值、legacy 镜像永不持 off)背后的兼容性设计。
背景:为什么这个状态补丁值得专门的测试计划
caveman 的 caveman 风格强度(lite / full / ultra / 文言各档 / commit 等)按会话应用,但早期实现把模式存在每个机器一个文件($CLAUDE_CONFIG_DIR/.caveman-active)里。从 caveman-config.js 中「Per-session mode state」注释块(L380–L403)看,这一单一存储形态直接造成了四个 bug:
- 并行会话共享同一个模式(窗口 A 切
ultra,窗口 B 跟着变); SessionStart在每次自动 compaction 后重新推导默认值,把用户明确的 "stop caveman" 冲掉;- statusline 徽章在每个窗口显示同一个模式(最后发言窗口胜出);
off以「文件不存在」表达,永远无法活过一次SessionStart——「没存」和「刻意关闭」无法区分。
#691 曾靠按 hook payload 的 source 分支修好了 compaction 冲掉档位变更的半边,但修不了其余三个——它们全是存储形态问题。补丁后的存储是 $CLAUDE_CONFIG_DIR/.caveman-sessions/<session_id>.mode(外加 .prev),legacy 标志保留为兼容镜像。正因为这条状态机涉及多个 hook 入口、文件读写与真实宿主进程,仓库才为它写了一份独立的三层测试计划。
第一层:自动化测试套件(钉死逻辑)
先跑 CI 门禁跑的全部四条命令(在仓库根目录):
npm test # installer + hook unit tests
node --test --test-force-exit tests/*.js # standalone Node suites
python3 -m unittest discover -s tests # Python suites
python3 tests/verify_repo.py # repo invariants
.github/workflows/ci.yml 在 ubuntu(Node 18/20/22 矩阵)与 macOS 上跑同样这四条,另有一个 windows-powershell job 负责解析每个 .ps1 文件、并透过真实 Git Bash 驱动 standalone 安装/卸载往返——因为 Claude Code 在 Windows 上的 hook shell 就是 Git Bash,hook 命令若 bash 解析不过,装机即死(CI 注释中引用的 #835)。
与这个补丁直接对话的测试套件:
| 套件 | 钉住的内容 |
|---|---|
| tests/hooks/caveman-config.test.mjs | Session-id 校验、路径包含检查、持久 off、prev 作用域、mode-log 打标签、GC。共 25 个用例。 |
tests/test_hooks.py 中的 SessionStartSourceTests |
source 分支——compaction 与 resume 不得重新推导默认值、不得复活已停用的会话;但会话激活时必须继续重新发出规则集。 |
tests/test_hooks.py 中的 test_hook_never_blocks_on_stdin_that_never_closes |
挂起防护。该用例不开写端、等 15 秒超时判定失败——靠超时报错而非断言(见 L392–L422:os.pipe() 保持写端打开,TimeoutExpired 即代表 hook 挂死)。 |
tests/test_mode_tracker.py 中的 SessionScopedModeTests |
两个窗口、各自独立模式;legacy 镜像永不持 off。 |
| tests/test_caveman_stats.js | statusline 的 stdin 解析,包括 traversal id 与畸形 JSON。 |
tests/verify_repo.py 中的 verify_powershell_static |
bash/PowerShell/JS 三方一致性(parity)+ hook 校验和清单。对 Windows 徽章输出而言,parity grep 是唯一的守卫——CI 的 Windows job 只覆盖安装与解析,不渲染徽章。 |
修改 src/hooks/ 下任何文件后必须重新生成完整性清单,否则 verify_repo.py 会因校验和不匹配而失败:
cd src/hooks && awk '{print $2}' checksums.sha256 \
| while read -r f; do printf '%s %s\n' "$(shasum -a 256 "$f" | awk '{print $1}')" "$f"; done \
> /tmp/sums && mv /tmp/sums checksums.sha256
清单本身在 checksums.sha256,verify_repo.py 从 L313 起逐行比对 checksums.sha256 声明的文件集与实际 SHA-256。
源码印证:每条测试钉住的是哪段实现。
SessionStartSourceTests对应 caveman-activate.js 中RESET_SOURCES = new Set(['startup', 'clear'])(L220)与run()里的分支(L286–L308):只有真startup或显式/clear才重算getDefaultMode();compact/resume/fork/未知 source 走「只读不推导」的续接分支,且读的是字面值(readSessionModeRaw),这样存储的off才与「从未写入」可区分。- 挂起防护对应同文件 L209 的
PAYLOAD_WATCHDOG_MS = 2000:payload 到达是事件驱动的(首个完整 JSON 对象即触发,不等 EOF),watchdog 覆盖「payload 永远不完整」的极端情形;触发时 source 记为unknown而非假定startup——否则一个慢 payload 的 compaction 事件会把用户会话中的ultra悄悄打回默认值。 - 路径逃逸防护对应 caveman-config.js 的白名单
SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/(L410)加上sessionStatePath()里冗余的 resolved-path 包含检查(L429–L436),双保险确保../../pwned这类 id 到不了文件系统。
第二层:端到端 smoke 测试(钉死接线)
bash tests/manual/session-mode-smoke.sh
session-mode-smoke.sh 用 Claude Code 真实下发的 JSON payload 驱动实际的 hook 二进制,跑在一次性 CLAUDE_CONFIG_DIR 上,绝不触碰你的真实 ~/.claude。期望结果是 22 passed, 0 failed;每个检查都打印自己的名字,失败时你能直接看出断链在哪一环。
它按一次真实会话的遭遇顺序覆盖 12 个步骤:启动写入 per-session 模式 → "stop caveman" 存入持久 off → compaction 不冲掉它 → resume 同样不冲 → 第二窗口保持自己的模式 → 每个徽章只显示自己窗口的模式 → 每轮强化(reinforcement)跟随会话而非机器 → 激活时 compaction 仍重新发出规则集 → traversal id 到不了任何文件 → 陈旧文件只在 startup 清扫、不在 compact 清扫 → 只带 legacy 标志的旧安装仍然工作(含跨 compaction)→ 无 payload 调用仍按旧行为工作 → stdin 永不 EOF 也不会楔死 hook。
脚本内部四个驱动函数(L33–L37)值得注意,它就是「人工单步调试」的模板:
activate() { printf '%s' "$1" | node src/hooks/caveman-activate.js 2>/dev/null; }
prompt() { printf '%s' "$1" | node src/hooks/caveman-mode-tracker.js 2>/dev/null; }
badge() { printf '%s' "$1" | bash src/hooks/caveman-statusline.sh 2>/dev/null | tr -d '\033' | sed 's/\[[0-9;]*m//g'; }
mode_of() { cat "$CLAUDE_CONFIG_DIR/.caveman-sessions/$1.mode" 2>/dev/null || echo '<absent>'; }
要手工观察单步,模式全程一致:
export CLAUDE_CONFIG_DIR=$(mktemp -d)
echo '{"session_id":"sess-A","source":"startup"}' | node src/hooks/caveman-activate.js
echo '{"session_id":"sess-A","prompt":"stop caveman"}' | node src/hooks/caveman-mode-tracker.js
echo '{"session_id":"sess-A"}' | bash src/hooks/caveman-statusline.sh
cat "$CLAUDE_CONFIG_DIR/.caveman-sessions/sess-A.mode"
最后一条应输出 off——注意 legacy 文件此时已不存在:writeSessionMode()(L487–L500)对 off 写入会话文件的同时 unlink legacy 镜像,且镜像永不持字面 off。这是降级安全的关键不变量,后面「混合版本安装」一节会解释为什么。
两个易踩的坑:
- 务必给
caveman-activate.js管道输入(哪怕< /dev/null)。终端裸跑会走isTTY分支直接返回;管道永远不关则等 2000 ms payload watchdog 后才激活——看起来像挂起,其实不是(对应源码 L247–L282 的 TTY/管道双分支)。 - smoke 第 12 步(脚本 L129–L147)专门验证这一点:spawn 出的 hook 在写端永不开的情况下必须在 4.5s 内退出。
第三层:真实 Claude Code(钉死无法伪造的部分)
徽章、compaction、插件自身的 hook 注册这三件事,任何 harness 都伪造不了。为了不动你的工作配置,手工把补丁后的 hook 接进一份 scratch 配置——必须手工,因为 --config-dir 只圈住 hook 文件和 settings.json,圈不住 claude plugin install,而 --only claude 会在途经时把插件装进你的真实环境:
TESTDIR=~/.claude-cavemantest
mkdir -p "$TESTDIR/hooks"
cp src/hooks/package.json src/hooks/caveman-*.js src/hooks/caveman-statusline.sh "$TESTDIR/hooks/"
NODE=$(command -v node)
cat > "$TESTDIR/settings.json" <<JSON
{
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "\"$NODE\" \"$TESTDIR/hooks/caveman-activate.js\"", "timeout": 5 }] }],
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "\"$NODE\" \"$TESTDIR/hooks/caveman-mode-tracker.js\"", "timeout": 5 }] }]
},
"statusLine": { "type": "command", "command": "bash $TESTDIR/hooks/caveman-statusline.sh" }
}
JSON
CLAUDE_CONFIG_DIR="$TESTDIR" claude
caveman-*.js 这个 glob 在这里做了实事:它顺带拷入 caveman-config.js 与 caveman-parse.js。tracker 缺少两者之一会静默降级为 no-op——看起来像补丁坏了,其实只是拷贝不全。对照 caveman-mode-tracker.js L56–L95 的 requireSibling 注释:形状检查只针对旧版必需的导出,per-session 辅助函数逐个解析并各自降级为 legacy 行为,避免「插件缓存漂移」把一个缺文件的安装变成整体 no-op。而 cavecrew-model-overrides.js 是真可选的(activate 里以 try/catch 加载,L177–L180)。
开始前先禁用已安装的 caveman 插件,否则两套 hook 并行跑,你分不清输出来自哪份拷贝。
A. 两窗口两模式。 开两个 Claude Code 窗口:窗口 1 说 /caveman ultra,窗口 2 说 /caveman lite。每个 statusline 显示自己的徽章,且在另一窗口继续打字时各自保持不变。补丁之前,两个徽章都跟随最后发言的窗口——caveman-statusline.sh 现在从 stdin 的 session JSON 提取 session_id(L26–L41,带白名单与 128 长度上限),优先读 $CFG/.caveman-sessions/<id>.mode,取不到才落回 legacy 标志。
B. 停用要活过 compaction。 窗口 1 说 stop caveman——徽章消失、回复恢复正常散文。然后 /compact:徽章保持消失,回复保持正常。这正是补丁要修的缺陷:#691 只挡住了 compaction 冲掉会话中的档位变更;停用原来写作「无标志文件」,hook 找不到存储、照旧重新推导默认值。
C. 激活状态要活过 compaction。 窗口 2 在 caveman 激活时跑 /compact,回复保持 caveman 腔。compaction 会把规则集从上下文里剪掉,所以 hook 必须在此处重新发出——「compact 时不触发」是方向错误的修法,会恰好破坏这一条。对应测试即 test_hooks.py test_compact_still_re_emits_the_ruleset_when_active(L303–L308)。
D. Resume。 退出窗口 1,用 claude --continue 回来:它仍是关的,因为 off 现在是存储值而非缺文件,resume 读存储态而非重新推导。注意这个检查不是说什么:全新窗口是新的 session_id 加 source: startup,它理应回到配置默认值——持久 off 的作用域是选它的那个会话,不是那台机器。/clear 是另一个刻意重置,因为对话里其余的一切都不活过它(所以 clear 进 RESET_SOURCES)。
E. Token 归因。 同一窗口里切换几次模式,跑 /caveman-stats,确认节省数字没被另一个窗口的行为污染。readModeLog 按 session_id 过滤迁移日志——上游依据在 caveman-config.js 的 recordModeChange()(L591–L603):{ts, mode, prev, session_id} 逐行追加到 .caveman-mode-log.jsonl,session_id 未知时直接省略该键,保持旧日志形状可读。
F. 卸载。
node bin/install.js --uninstall --config-dir ~/.claude-cavemantest
ls -a ~/.claude-cavemantest
.caveman-active、.caveman-active.prev、.caveman-mode-log.jsonl、.caveman-statusline-suffix、.caveman-nudge-shown 与 .caveman-sessions/ 目录应全部消失;.caveman-history.jsonl 有意保留——那是用户累积的终身节省量,不是 caveman 的管线文件。之后 rm -rf ~/.claude-cavemantest。同一份清理清单也在 uninstall.sh 与 uninstall.ps1 里——如果你动过它们,值得各跑一遍,因为经 shell 脚本安装的用户仍依赖它们。
混合版本安装:一个真实的配置形态
值得花十分钟,因为它真实存在:插件 hook 与 standalone hook 可以同时注册,而 statusLine 持有安装时烤进去的绝对路径——一份打过补丁的 hook 和一份没打的可以对着同一个状态目录运行。
升级。 在一个没有 .caveman-sessions/ 的全新配置里放一个裸的 printf 'lite' > $CLAUDE_CONFIG_DIR/.caveman-active,然后开会话。caveman 以 lite 启动,并跨 compaction 保持 lite。smoke 第 10 步(脚本 L107–L119)已覆盖:注意断言里刻意设了 CAVEMAN_DEFAULT_MODE=ultra,验证的是「续接分支读到 legacy 镜像的 lite 后没有落回环境变量默认值」——与 caveman-activate.js L296–L300 的升级路径(stored === null 时读 legacyFlagPath)一一对应。
降级。 在补丁后 hook 写出的状态目录上检出旧版 hook,确认旧代码永远不会看到它解析不了的档位。让它安全的那个不变量:legacy 镜像永不持字面 off,停用即 unlink。因为 off 已在 VALID_MODES 白名单里(caveman-config.js L32–L36),若旧版 caveman-mode-tracker.js 从 legacy 路径读到 off,会通过其 !INDEPENDENT_MODES.has(...) 检查并注入 "CAVEMAN MODE ACTIVE (off)";旧版 caveman-statusline.sh 则渲染出 [CAVEMAN:OFF]。若将来你改变「停用如何写状态」,这是第一个要重新检查的点——新版 statusline 对 off 的行为见 caveman-statusline.sh L65–L68:渲染什么都不渲染。
陈旧的同级文件。 三个 hook 入口各自单独解析 per-session 辅助函数,而非把它们塞进 requireSibling 形状检查——所以一份补丁前的 caveman-config.js 会降级为机器级行为,而不是把 hook 变成 no-op(caveman-activate.js L156–L173 的逐函数 fallback stub 就是按旧行为复刻的)。tests/test_hook_missing_sibling.js 覆盖「文件缺失」情形;「文件陈旧」情形由本段加上上面的降级运行覆盖。
明确未覆盖的部分
- PowerShell 徽章行为。 CI 的 Windows job 解析每个
.ps1并驱动安装/卸载,但没有任何东西渲染徽章,所以caveman-statusline.ps1只被 verify_repo.py 里的 parity grep 检查(verify_powershell_static,L376 起)。改它意味着在 Windows 上手工测。 - opencode。 src/plugins/opencode/plugin.js 仍是机器级行为,在范围之外:它往 opencode 配置目录写自己的标志,永远看不到 Claude Code 的
session_id。
关键源码坐标速查
| 关注点 | 位置 |
|---|---|
source 分支与 2000ms payload watchdog |
caveman-activate.js |
| 续接分支读字面值、legacy 升级回退 | caveman-activate.js |
writeSessionMode / readSessionModeRaw / gcSessionStore |
caveman-config.js |
| session id 白名单与路径包含检查 | caveman-config.js |
| 每轮强化与 one-shot 恢复 | caveman-mode-tracker.js |
徽章按 session_id 选源、拒绝符号链接 |
caveman-statusline.sh |
| 22 项端到端检查 | tests/manual/session-mode-smoke.sh |
SessionStartSourceTests 全分支用例 |
tests/test_hooks.py |
| CI 四平台门禁 | .github/workflows/ci.yml |
适用前提:以上步骤假设你在本仓库检出目录内操作(smoke 脚本第 13 行会 cd 回仓库根),Node 版本与 CI 矩阵一致(18/20/22),且 CLAUDE_CONFIG_DIR 指向一次性目录——所有实机验证都通过环境变量把状态隔离在 scratch 目录里,这也是整份计划的可复制核心:每个层次只依赖上一层次的结论,任何一层失败都能把问题域缩小到 hook 逻辑、hook 接线或宿主行为之一。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00