机制要么挂在事件上,要么等于不存在
我手上同时开着四五个项目。切回其中一个的时候,前二十分钟基本是同一套动作:翻文档、翻聊天记录、git log 看最近改了什么,然后才想起来上次卡在哪。干活本身反而不占时间。
AI 把执行成本压下去之后,事情不是变少了,是变多了。省下来的时间没落到我身上,落到了"看不过来"上。真正稀缺的是注意力,不是执行力。
所以我给工作项目单独写了一个 repo,叫 agent-harness,全部代码两千行出头。目标只有一个:把注意力从三件事上撤出来。session 开头重新搞清楚项目状态、session 结尾记得把结论写下来、过程中反复被问"要不要继续"。
靠自觉触发的机制,合规率一定会掉到零
我先承认一个失败。之前写过一堆 skill,规定"调查完要更新文档"“重要结论要记进 OBSERVATIONS.md”。头两周执行得很好,一个月后基本没人(包括我自己和 agent)想得起来。
这跟执行力无关,是结构问题。任何需要人或模型在某个时刻主动决定"现在要不要用它"的机制,长期合规率都会衰减。因为决定用不用它本身就是一次注意力开销,而这套机制存在的理由恰恰是省注意力。
于是这个 repo 只有一条分类规则:必须发生的事挂在 hook 上,需要判断的事做成主动触发的 skill。
| 必须发生 | 挂哪 | 需要判断 | 做成什么 |
|---|---|---|---|
| session 开头加载项目状态 | SessionStart |
哪些结论已经过期 | /harness-compact |
| 每轮结束留下记录 | Stop |
怎么拆维度并行调查 | /harness-explore |
| 越界动作被拦住 | PreToolUse |
一个新项目该怎么配 | /harness-onboard |
六个 hook 挂在五个事件上,全局装一次,行为由项目目录里的 .harness/project.yml 决定。没有配置的目录静默返回,只注入一份全局默认契约,不报错也不拖慢。
写的时候不判断
第二条原则是 WAL 加 compaction,数据库那套。
写路径只管顺序 append。Stop hook 每轮无条件往 .harness/journal/YYYY-MM-DD.jsonl 追一条记录:时间、session id、cwd、git branch 和 HEAD、工作区脏文件数、本轮最后一条回复的正文(截断到 4000 字符)。不调模型,不做摘要,不判断重不重要,开销就是一次 python 启动加一次文件 append。
原因很实在:任何在写入时刻要求做判断的设计,摩擦都会立刻回来。"这条结论重要吗,要不要记"这个问题一旦出现在写路径上,这个机制就死定了。
整理是异步、批量、延迟发生的。/harness-compact 读 journal 和文档,判定覆盖关系,产出收敛后的 STATE.md。而"从一条时间线里识别哪些结论被后来的结论推翻了"正好是模型擅长的活。
读路径永远不读 WAL,只读 compaction 的产物。所以不管项目跑多久,session 起点的上下文体积是有上限的(默认 8000 字符)。超了就是没收敛干净,回去继续合并,不能靠调大上限解决。
CLAUDE.md 越写越像一份带勘误的日志
这套结构真正解决的是我以前维护 CLAUDE.md 的方式。
那个文件同时承担了两个角色:它既是历史记录(我们讨论过什么、试过什么),又是当前状态(现在的口径是什么)。这两个角色的更新方式是冲突的。历史只能追加,当前状态必须能被推翻重写。混在一起的结果是正文里开始出现"注:这条已被推翻,见下文",然后文件越来越长,可信度越来越低,最后没人愿意读。
拆开之后干净了。STATE.md 只写当前有效的,每条事实必须带出处(file:line、命令、或 journal 日期),写不出出处就降级成开放问题。被推翻的内容不删,git mv 进 .harness/archive/<日期>/,同目录留一份清单,四列:原路径、归档原因、被什么取代、推翻时间。
归档这条是硬规则:compaction 阶段禁止 rm。行为可回溯是我敢让它自动整理的前提条件。skill 里还写了一步开工前的自检,跑 git check-ignore 探一下归档目录在不在 gitignore 覆盖范围内,命中就停下来报错。把被跟踪的文档 git mv 进一个被忽略的目录,等于从仓库里静默删掉它们,而这正好是这套机制要防的事。
安全区内不问,安全区外拦住
第三件事是"不在不合适的地方停下来"。
PreToolUse hook 读项目声明的爆炸半径,三种判决。命中 allow_prefixes 直接放行,不弹权限提示;命中 deny_patterns 或禁止的 k8s context 硬停并说明原因;其余交回正常权限流程。
一个细节:我们团队约定所有 mutating 的 k8s 命令前面要加一行 # INTENT: 注释说明为什么这么干。所以前缀匹配之前先把注释行剥掉,否则每条带 INTENT 的命令都会因为第一个字符是 # 而匹配不上任何前缀。
反过来的方向同样重要。安全区外不是"问一下",是拦住。写操作可以限定在 write_roots 里的几个相对路径之内,超出范围直接 deny。
Stop 上还挂了一个一次性的续跑守卫。如果这一轮结束在"要我继续吗"上,而回复里没有显式写 【需要决策】,hook 会把它推回去一次,附上 envelope 的摘要,要求真有歧义时用 【需要决策】 开头并给出选项和取舍。一个 session 只推一次,靠 ~/.harness/run/ 下的标记文件保证不会循环。
同一条规则写两遍
输出格式契约走 SessionStart 注入,每个 session 无条件生效。骨架是【结论】【证据】【问题】【下一步】四段,配一张说明什么内容该用 bullet、什么该用表格、什么该用自然段的对照表。
长篇散文的问题在于它把压缩的工作留给了读者。契约把压缩挪回写的那一侧。
但只注入一次不够。长 session 里模型会漂,SessionStart 那段 context 也可能在压缩中被丢掉。所以还有一个 UserPromptSubmit hook,每轮在用户输入后面追加一句话的提醒,只提一行指针,不重复整个骨架。Stop 上再放一道兜底:回复超过 700 字符且一个段落标记都没有,提醒一次。
三层里只有一份真相。契约正文写在 skills/harness-output-format/SKILL.md,两个 hook 都通过 load_skill_body() 从这个文件读正文并剥掉 frontmatter。改一处,两个 hook 同时生效。文件读不到时降级到 hook 里硬编码的 fallback,因为一个 skill 文件缺失或者软链断掉,不应该让整轮对话失败。
说了什么,和动了什么
journal 记的是说了什么,PostToolUse 上的 oplog 记的是动了什么。
任何写文件、移文件、改集群的动作都在 oplog 留一条,带上 # INTENT: 里的意图和当时的 git ref。判定用一条写得比较宽的正则,只读命令一律跳过,否则 oplog 会被 ls 和 grep 淹掉。宽窄的取舍是明确的:漏记比多记贵。
这两条流分开之后,"上周三到底谁把那个文件挪走了"这类问题有地方查了,不用去翻聊天记录。
并行不做默认
/harness-explore 里写死了一条:拆不出正交维度就串行做完,不要为了并行而并行。
并行的收益等于省下的时间减去合并的成本。三个 agent 查三个正交维度,结果不重叠,合并接近零成本,值得并行。三个 agent 对同一个问题各给一份看法,合并成本原样落回我脑子里,瓶颈是被乘以三不是除以三。
正交性有个很好用的自检:如果维度 A 的 agent 和维度 B 的 agent 会读到同一批文件,它们不正交,合并成一路。
subagent 只回固定 schema(FINDINGS / GAPS / CONTRADICTS),一条事实一行,带出处和置信度,明确要求"返回内容就是数据,不是给人看的汇报"。冲突消解、补因果链、把没出处的低置信条目降级进 GAPS,这三件事由主 agent 自己做,不外包。
hook 里绝不调模型,也绝不失败
两条工程约束贯穿全部代码。
hook 在关键路径上,一次 claude -p 是几十秒。所有需要模型的工作都放在 skill 或 cron 里,hook 只做文件读写和正则匹配。
每个 hook 的入口都包在 safe_main() 里,任何异常都退化成静默 no-op 并 exit 0。因为一个 harness 出的错,绝不能表现成用户那一轮对话失败。YAML 解析也做了同样的处理:优先用 PyYAML,ImportError 时降级到一个只覆盖本项目 schema 子集的缩进解析器,换台没装 PyYAML 的机器照样能跑。
还有一个查了半天的坑。找项目根的时候不能只判断 .harness/ 目录存在,因为 ~/.harness 是全局配置目录,只看目录会让 $HOME 下所有路径都把 $HOME 当成项目根。标记必须是 .harness/project.yml 这个文件。
记忆是碎的
Claude Code 的自动记忆按 cwd 分目录存放,slug 是绝对路径把非字母数字字符全换成横线。同一个仓库,从根目录进和从子目录进,落到的是两个不同的记忆目录,而当前 session 只会自动加载 cwd 对应的那一份。
所以 SessionStart 会额外扫一遍同项目下没被加载的记忆索引,把它们的目录和标题列出来,让 agent 知道还有东西可以按需读。同时明确声明权威顺序:STATE.md > journal > 自动记忆。记忆记的是写下那一刻的事实,可能已经过期,引用前先核实它提到的文件、函数、参数还在不在。冲突时以 STATE.md 为准,把冲突记进开放问题,等下次 compaction 裁决。
compaction 也要逐个目录扫这些记忆,因为它们经常比文档新。写一条记忆的门槛远低于改一份文档。
装、验、拆
1 | harness install # 软链 hooks 和 skills,把条目并进 settings.json |
install 全部幂等,写 settings.json 之前先备份,只增加 harness 自己的条目,不碰别的。doctor 有 FAIL 时退出码非零,可以直接进 cron。
定时收敛靠一个 shell 脚本调 claude -p /harness-compact,三重守卫:没有 .harness/ 跳过、距上次收敛不足七天跳过、工作区脏跳过。最后这条是防止在别人没提交的改动上面做归档,那样出了问题没法回溯。无人值守模式下 prompt 里额外写死一句:判不准的一律保留并标注存疑,不要猜。
它自己也会漂
写这篇的时候我对了一遍 README,发现它还写着"五个 hook",表格里没有 oplog,目录结构图里也没有 oplog.jsonl。oplog 是两天前加的,hook 代码和 manifest 都进去了,README 那两处忘了改。发现的当下就补上了,加上这一段总共花了五分钟。
问题在于这五分钟是我人肉发现的,不是机制抓出来的。开头那条原则在这里反过来打了自己一下:靠"记得同步文档"这种自觉维持的一致性,衰减速度和当初那些没人执行的 skill 一模一样。真正能兜住漂移的只有留痕加定期比对,而 oplog 里其实明明白白记着两天前有几次对 hooks/ 和 install/ 的写入,README 却一行没动。
所以 compaction 现在的输入还不够。它比的是 journal 和文档,看不到代码侧。下一步是把窗口内的 git log --stat 和 oplog 一起塞进去,让它回答一个具体问题:这个窗口里被改动的模块,对应的文档段落有没有跟着动。这个判断模型完全做得了,缺的只是把数据递到它面前。