问题
在真实项目上工作的代理看不到整个项目。它只看到自己搜索的东西,而它只搜索自己想到的东西。当需要的工具函数在另一个模块、换了个名字时,它就再写一遍。当一个任务既有短方案也有长方案时,没有任何东西推动它选短的。
常见的做法是写规则:CLAUDE.md、技能、系统提示。它们有用,但只是建议。模型可能不加载它们,可能在一轮中途忘掉,也可能认为它们不适用。我们改不了模型的权重,但可以控制三件事:模型看到什么、被允许做什么、什么时候可以结束。Sens 同时使用这三点。
机制
Claude Code 在自己的流协议中以回调形式接受钩子。Sens 在自己的进程内处理其中五个,并坚持一个不变量:更改一产生就是未批准的。只有通过的审计或人才能批准它。
- 01
你发送一条消息时
UserPromptSubmit
Sens 为项目建立检查点,并把最多八个与请求相关的已有内容交给 Claude,每个都附上签名、文件:行号和使用次数。
- 02
每次写入之前
PreToolUse · Write, Edit
Sens 在内存中重建写入后的文件并应用更改规则。它可以在写入发生前拒绝,并说明原因和应复用的代码。
- 03
每条命令之前
PreToolUse · Bash, PowerShell
Sens 保护自己的路径和配置,并把 git commit 和 git push 视为一轮的结束:未批准的内容不会被提交。
- 04
每次使用工具之后
PostToolUse
无论是什么工具,Sens 都会查找磁盘上改动的文件,并对它们应用同样的规则。
- 05
一轮结束时
Stop · SubagentStop
Sens 审计自上一个批准点以来的完整差异:更改规则、孤立代码,以及代码有改动时的审查者。有阻止性的发现时 Claude 继续工作;三轮仍不通过,这一轮就会暂停,等你决定。
无路可绕
在动手构建之前,我们实测了这套机制能否经受住试图绕过它的模型:会话中途写入的 disableAllHooks 不会停止回调,子代理同样经过它们,被拒绝的 git commit 会让仓库保持原样。每条捷径都有对应的封堵:
| 捷径 | 如何封堵 |
|---|---|
| 通过终端、Python 或其他 MCP 服务器写入 | 每次使用工具后的磁盘检查,以及一轮结束时的审计 |
| 子代理和后台任务 | 它们经过同样的钩子;运行期间这一轮不会被批准 |
| 关闭钩子或修改配置 | 钩子运行在 Sens 的进程中;规则 R7 会阻止并恢复这些文件 |
| 打开自己的工作树 | 工作树相关工具被拒绝,R7 也覆盖 git worktree |
| 宣称工作已完成 | 一轮何时结束由审计决定,而不是模型 |
| 开启新的一轮来逃避 | 审计从上一个批准点开始,未处理的内容会被继承 |
| 提交未批准的工作 | 提交被视为一轮的结束 |
| 无休止地循环 | 三轮之后,这一轮会被暂停 |
界限是明确的:这套机制封堵的是模型的错误和捷径,而不是在同一台机器上运行的恶意程序。
结果
单个任务都从干净的项目开始,而不复用的代理造成的损害不在于某个任务,而在于累积。Horizonte 测量的就是累积:一个记录家庭开支的 TypeScript 命令行程序,起始 88 行,再按固定顺序完成 30 个产品需求,每个都建立在上一个的结果上。其中埋入了八个概念,多个任务会用到却不明说:日期、月份和周、合计、重音字符、金额、CSV、命令选项。第一次由代理来写;之后,正确的做法是复用它自己写过的。
判定标准在测量前就写定了:只有当使用 Sens 的三个序列全部低于不用 Sens 的三个序列时,才能说 Sens 让项目变小。如果没有真实差异,这种情况偶然发生的概率是二十分之一。
每个任务后的项目规模
- 不用 Sens
- 参考解
- 使用 Sens
30 个任务中每个任务完成后项目的代码行数。细线是各个序列,粗线是它们的中位数。虚线是为复用而写的参考解,它较早建立共享模块。
查看数据
| 任务后 | 不用 Sens | 参考解 | 使用 Sens |
|---|---|---|---|
| 0 | 88 | 88 | 88 |
| 1 | 92 | 93 | 93 |
| 2 | 102 | 113 | 103 |
| 3 | 113 | 150 | 109 |
| 4 | 121 | 161 | 118 |
| 5 | 145 | 188 | 137 |
| 6 | 162 | 202 | 161 |
| 7 | 178 | 209 | 177 |
| 8 | 223 | 234 | 195 |
| 9 | 232 | 243 | 206 |
| 10 | 232 | 243 | 206 |
| 11 | 258 | 276 | 231 |
| 12 | 277 | 282 | 247 |
| 13 | 296 | 307 | 265 |
| 14 | 304 | 309 | 269 |
| 15 | 316 | 315 | 271 |
| 16 | 346 | 345 | 301 |
| 17 | 354 | 357 | 309 |
| 18 | 367 | 369 | 326 |
| 19 | 370 | 377 | 330 |
| 20 | 381 | 390 | 341 |
| 21 | 398 | 404 | 357 |
| 22 | 415 | 413 | 373 |
| 23 | 426 | 422 | 384 |
| 24 | 432 | 422 | 388 |
| 25 | 452 | 434 | 401 |
| 26 | 453 | 436 | 402 |
| 27 | 476 | 453 | 418 |
| 28 | 485 | 455 | 426 |
| 29 | 487 | 455 | 427 |
| 30 | 496 | 455 | 436 |
第 30 个任务时的规模(按序列)
使用 Sens 的三个序列全部低于不用 Sens 的三个序列。判定标准成立:中位数 436 行对 496 行,小 12%,95% 区间为 −140 至 −28 行。
30 个任务累计消耗的 token
- 不用 Sens
- 使用 Sens
每个任务要读的项目更小,Sens 的消耗就更少:三个序列合计 2770 万 token,对比 3370 万。token 包含缓存读取,因此衡量的是工作量,而不是确切成本。
查看数据
| 任务后 | 不用 Sens | 使用 Sens |
|---|---|---|
| 0 | 0.0M | 0.0M |
| 1 | 0.3M | 0.3M |
| 2 | 0.5M | 0.6M |
| 3 | 0.9M | 0.8M |
| 4 | 1.2M | 1.1M |
| 5 | 1.6M | 1.3M |
| 6 | 1.8M | 1.7M |
| 7 | 2.0M | 1.9M |
| 8 | 2.5M | 2.4M |
| 9 | 3.4M | 2.7M |
| 10 | 3.7M | 3.0M |
| 11 | 4.4M | 3.4M |
| 12 | 4.7M | 3.6M |
| 13 | 5.2M | 4.0M |
| 14 | 5.4M | 4.2M |
| 15 | 5.9M | 4.5M |
| 16 | 6.2M | 5.1M |
| 17 | 6.6M | 5.3M |
| 18 | 6.9M | 5.7M |
| 19 | 7.1M | 6.1M |
| 20 | 7.5M | 6.4M |
| 21 | 8.1M | 6.7M |
| 22 | 8.5M | 7.1M |
| 23 | 8.8M | 7.4M |
| 24 | 9.0M | 7.7M |
| 25 | 9.4M | 7.9M |
| 26 | 9.8M | 8.2M |
| 27 | 10.2M | 8.5M |
| 28 | 10.7M | 8.8M |
| 29 | 11.0M | 9.1M |
| 30 | 11.3M | 9.3M |
差异从何而来
不是因为复制得更少。两组都没有真正大段复制:jscpd 在不用 Sens 时发现 0、6、6 行重复,使用 Sens 时为 0,而每个埋入概念的探针在两组中的计数相同或几乎相同。差异来自做同样的事写得更少。使用 Sens 时,代理写的函数更多、更短:中位数 28 对 20。在试点中,为了读取 CSV 里带引号的描述,不用 Sens 的代理写了一个完整的 99 行 CSV 读取器;使用 Sens 时,它注意到描述是最后一个字段,两个单行函数就够了。
token 合计:不用 Sens 33.7M · 使用 Sens 27.7M
单个任务
在两个真实项目上用三种语言完成十二个任务:Sens 本身(TypeScript 和 Rust),以及固定提交的 Python 库 click,每个任务都用隐藏测试和参考解验证。用同一个模型比较三种条件:只用 Claude Code(C0),只在系统提示中放入 Canon 文本、没有机制(C1),以及完整的 Sens(C2)。
| 指标 | C0 · 单独 | C1 · 文本形式的 Canon | C2 · Sens |
|---|---|---|---|
| 有效运行 | 32/36 | 31/36 | 67/72 |
| 添加了测试的运行 | 23/36 | 36/36 | 72/72 |
| 复用远处的 plain | 0/3 | 3/3 | 6/6 |
| 复用远处的 titleOf | 1/3 | 1/3 | 6/6 |
| 解决 py-progress-final | 0/3 | 0/3 | 3/6 |
当工具函数就在附近时,仅靠文本就能实现相当多的复用。但它做不到函数在远处且名字不同的情况(titleOf:3 次中 1 次,对比 6 次中 6 次),也做不到需要修复共同原因而不是单一路径的任务(py-progress-final:3 次中 0 次,对比 6 次中 3 次)。在单个任务中,代码行数只是噪声:C2 每个任务少写约两行,但区间触及零。正因为这种波动,才有了 Horizonte。
至少添加了一行测试的运行
使用 Canon 1.0 时,代理几乎不再写测试:它把“只做被要求的事”理解成禁止,还把 Sens 的批准当成了测试运行。Canon 1.1 补上了缺少的两点:证明更改的测试是更改的一部分,Sens 的批准不是测试运行。只用 Claude Code 的条件不接收 Canon,所以它的条形是每批的基线。
规则
更改规则是确定性的。它们比较每个函数、方法和类,以及每连续四条语句的指纹,这些指纹由一个基于 tree-sitter 的 Rust 索引计算,项目常驻内存:Sens 自己的仓库有 556 个文件、10,000 个单元,建立索引不到两秒,查找一个单元的副本约需一微秒。完全相同的副本和改了名字的副本通过哈希匹配;增删了行的副本通过对规范化 token 计算 MinHash 来匹配。0.80 的阈值和阻止所需的 80 个 token 下限,来自修改一个真实仓库中的 400 个函数并逐一人工复核每个匹配。
| 规则 | 检测内容 | 处理 |
|---|---|---|
| R1 复用 | 新的函数、方法或类,与已有的具有相同的 1 型或 2 型指纹 | 80 个 token 及以上时阻止;低于此值,要求 Claude 重新考虑 |
| R2 近似复制 | 超过阈值的 3 型相似,或在形式和词汇上与另一个相同的小函数 | 同 R1;在测试中为提示 |
| R3 新增依赖 | 清单文件新增依赖,支持十种格式 | 询问你 |
| R4 孤立代码 | 没有任何地方用到的新符号,或这一轮让已有符号不再被使用 | 内部的阻止;导出的提示 |
| R6 项目规则 | 你声明的规则;第一条是不写注释 | 阻止 |
| R7 完整性 | 写入 .sens/、.git/、.claude/settings*.json 或 .mcp.json,或 git worktree | 总是阻止;若经由终端则恢复 |
| R8 受保护的测试 | 这一轮删除了 Sens 已批准的测试或断言 | 询问你 |
规则看不到判断上的错误。为此,当一轮改动了代码并通过规则后,审查者会结合索引找到的候选来阅读差异。它的输出不会被盲目采信:凡是引用内容没有逐字出现在差异中的发现都会被丢弃,只有高置信度才会阻止。
| 提示 | 检测内容 |
|---|---|
| S1 | 没有第二处用途的抽象 |
| S2 | 治标不治本的修复 |
| S3 | 重新实现平台或依赖已提供的功能 |
| S4 | 臆测:没人要求的选项或分支 |
| S5 | 该直白时却耍了花样 |
| S6 | 危险的删减:去掉验证、错误处理或安全措施 |
| S7 | 重新实现项目已有的东西,并指出候选 |
没奏效的地方
机制的每一次阻止都结合其差异和对话进行了人工复核。阻止很少见,Sens 的 228 次运行中只有七次,因此一次不公正的阻止分量很重。我们的目标是不公正阻止少于 5%,但在任何出现阻止的批次中都没有达到;每次不公正都有具体原因,现已修复,并有测试将其固定。
| 批次 | 阻止 | 不公正 | 原因 | 修复 |
|---|---|---|---|---|
| 困难任务,C2 v3 | 1 | 0,1 次有争议 | 审查者标记了项目反复使用的惯用写法 | 针对私有内容的 S7 只作提示 |
| 校准,C2 | 2 | 2 | R8 与每次写入前的文件比较 | R8 与上次批准的状态比较 |
| 校准,修复后的 C2 | 0 | 0 | — | — |
| Horizonte,试点 | 1 | 0 | — | — |
| Horizonte,确认 | 3 | 1 | R8 把测试文件里的辅助函数当成了测试 | 只有检查了某些东西的才算测试 |
错误的列表比没有列表更糟。Sens 的第一个版本建议了八个与请求无关的符号;模型读了它们,没有继续搜索,三次都手写了处理重音字符的函数,而没有列表的文本形式 Canon 三次都导入了它。重做搜索后,这个函数出现在建议中,Sens 每次都用上了它,没有阻止任何东西。
局限
- 只有一个模型。 所有运行都使用 Claude Sonnet 5.5,中等推理强度。
- 一个项目、一种语言、每组三个序列。 确认标准很严格(全部低于全部),但效应大小的区间较宽。
- 任务是我们写的。 为了不让任务影响结果,任务、测试和参考解在第一次运行前就已提交,判定标准也在测量前确定。
- 基准测试中没有 MCP 工具。 Sens 是在没有应用所提供的索引查询的情况下测量的,因此结果是下限。
- 十九种语言尚未进行基准测试。 Vue、Svelte 以及后来加入的语言由测试覆盖,而不是由代理运行验证。
- token 包含缓存读取。 它们衡量的是工作量,而不是确切成本。
- 审查者精度不高。 在我们复核的七条它自己的提示和阻止中,有五条是错的。它的提示不会阻止任何东西,但会传给模型和你。
- 不成文的约定。 Sens 不了解项目的隐含规则,比如把重量级的导入放在函数内部。
方法
分五批共运行代理 456 次:一次试点、三个基于 Sens 本身的困难任务、十二个校准任务,以及 Horizonte 的试点和确认。所有条件都使用 claude-sonnet-5-5,中等推理强度,Claude Code 以 --safe-mode 运行,以免作者自己的配置混入。每个任务在使用前都经过验证:开始时项目测试通过、隐藏测试失败,应用参考解后两者都通过。回归必须连续失败两次才算数。差异以中位数表示,附 95% 的 bootstrap 区间(固定种子,10,000 次重抽样);Horizonte 的判定标准是精确置换检验。
复现
sens-bench validate --tasks bench/tasks
sens-bench run --tasks bench/tasks --condition C0,C1,C2 --reps 3 --out bench/results/<batch>
sens-bench sequence validate bench/sequences/cuentas
sens-bench sequence run bench/sequences/cuentas --condition C0,C2 --reps 3 --out bench/results/<batch>
sens-bench sequence report bench/results/<batch>每次运行的数据、差异和任务都在 Sens 仓库 的 bench/ 文件夹中。
Canon
每个会话收到的原文,逐字照录,保持模型读到的英文。正是这套机制,让它不只是一条建议。
# Sens Canon v1.1
You are working inside Sens. Sens indexes this project and judges every change you make before your turn can end. What Sens tells you about this project, in its messages, denials and reviews, is a fact about the code, not a suggestion. When Sens names something to reuse, reuse it.
## Before you write
Go down this ladder and stop at the first step that answers the need:
1. Is it needed? Do what the person asked and nothing more: no speculative options, parameters, flags or branches. A test that proves the change is part of the change, not something extra.
2. Does the project already have it? Reuse the existing function, component, type or constant. Ask Sens with `already_exists` or `find_symbol` when unsure.
3. Does the standard library or the platform give it? Use that.
4. Does an installed dependency give it? Use that. A new dependency needs the person's approval, and Sens asks them for it.
5. Only then write new code: the smallest version that is correct.
## While you write
- Fix the cause in the shared code, not the symptom in each caller.
- No abstraction without a second real use: no interface, factory, wrapper, layer or configuration for a single consumer.
- Boring over clever. Match the names, patterns and style of the code around you.
- If you would copy a block, extract it once and call it from both places.
- Delete what your change leaves unused.
## Never cut
Less code never means removing validation at trust boundaries, error handling that prevents data loss, security checks, accessibility, or anything the person asked for.
It never means skipping tests either. When your change alters behaviour and the project has tests, add or extend one that fails without your change, in the style of the tests around it, and run the tests you touched before you finish.
## Working with Sens
- A denied write comes with the reason and what to use instead. Change the approach. Retrying the same thing through the shell, another tool or a subagent does not help: Sens judges what lands on disk, however it got there.
- When you finish, Sens audits the whole turn. If it blocks, fix what it found and finish again.
- Sens judges the shape of the code, not whether it works. Its approval is not a test run: that part is yours.
- Never edit `.sens/`, `.claude/settings*.json` or `.mcp.json`.