首页
/ caveman 会话级模式状态测试计划:三层验证体系与 Claude Code Hook 状态机实践

caveman 会话级模式状态测试计划:三层验证体系与 Claude Code Hook 状态机实践

2026-09-06 15:30:02作者:凌朦慧Richard

本文基于 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:

  1. 并行会话共享同一个模式(窗口 A 切 ultra,窗口 B 跟着变);
  2. SessionStart 在每次自动 compaction 后重新推导默认值,把用户明确的 "stop caveman" 冲掉;
  3. statusline 徽章在每个窗口显示同一个模式(最后发言窗口胜出);
  4. 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.sha256verify_repo.py 从 L313 起逐行比对 checksums.sha256 声明的文件集与实际 SHA-256。

源码印证:每条测试钉住的是哪段实现。

  • SessionStartSourceTests 对应 caveman-activate.jsRESET_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.jscaveman-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_idsource: startup,它理应回到配置默认值——持久 off 的作用域是选它的那个会话,不是那台机器。/clear 是另一个刻意重置,因为对话里其余的一切都不活过它(所以 clearRESET_SOURCES)。

E. Token 归因。 同一窗口里切换几次模式,跑 /caveman-stats,确认节省数字没被另一个窗口的行为污染。readModeLogsession_id 过滤迁移日志——上游依据在 caveman-config.jsrecordModeChange()(L591–L603):{ts, mode, prev, session_id} 逐行追加到 .caveman-mode-log.jsonlsession_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.shuninstall.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 接线或宿主行为之一。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388