决定结果的不是模型,而是周围
用的是同一个模型,在某个仓库里代理会用 git stash 抹掉别人的编辑,在另一个仓库里这种事从不发生。差别不在模型,而在围绕模型的东西,也就是 harness:规则写在哪里,拦截哪些命令,提交要经过哪些关口,会话结束后还留下什么。
这篇文章是对 LabHub 仓库 harness 的逐文件解剖记录。这个仓库自 2026 年 8 月 20 日首次提交起,三周里 AI 代理与人一起工作,结果以数字留了下来。
| 项目 | 值 | 来源 |
|---|---|---|
| 全部提交 | 1,491 | git rev-list --count origin/main |
| AI 共同署名的提交 | 497(Opus 354 · Fable 136 · Codex 7) | 提交正文里的 Co-Authored-By |
| 生产部署提交 | 350(开发环境 295) | deploy(prod): 前缀 |
| 最近 7 天提交(不含部署) | 377 | 9 月 4 日至 11 日 |
| 单元测试数 | 1,697 → 2,346 | 门禁日志,9 月 7 日 10:47 → 9 月 11 日 09:06 |
| 这台 Mac 上运行的门禁 | 161 次 | 9 月 7 日以来的门禁日志文件数 |
最后一行是本文的主题。在这个工作会话的日志里数一数:push 成功 127 次,被门禁拦下 41 次,origin 先行而把提交重新放上去 23 次。harness 不是文档,而是一天转几十次的机器。
原则一:规则放一处,装置另放
整个 harness 是在 2026 年 9 月 7 日的一次提交(83821fa0,35 个文件,1,746 行)里进来的。提交信息说明了为什么做它。
规则一直只在 AGENTS.md 一处,可是守住规则的装置却每个会话都在草稿区重做
(push.sh、gate.sh、bumpdigest.py)。会话一结束就消失,下一个会话要再经历
一次同样的事故之后,才把同样的工具再做一遍。
所以结构的第一原则是 规则只有一个原本。规则在 AGENTS.md(707 行)一处,Claude Code 读取的 CLAUDE.md 只是指向它的指针。照抄 CLAUDE.md 的原话:
同样的内容分写在两处,必定有一处被留在过时的状态。
.claude/README.md 这样规定 .claude/ 下放什么:
规则的原本是 AGENTS.md。这里只放 Claude Code 才读的装置 —
不是把规则再写一遍,而是为了让规则得到遵守而自动介入的东西。
五层,再加两样
README 把 harness 分为五层。我数了实际的文件数附上。
| 层 | 什么 | 文件 | 作用 |
|---|---|---|---|
| 永久指引 | AGENTS.md、CLAUDE.md | 2 | 每个会话最先读的规则与事故记录 |
| 路径规则 | .claude/rules/*.md | 6 | 只在触碰特定路径时附上的指引 |
| 技能 | .claude/skills/*/SKILL.md | 5 | 重复过三次以上的流程 |
| 钩子 | .claude/hooks/*.py | 2 | 在工具调用前后介入的检查 |
| 子代理 | .claude/agents/*.md | 3 | 只读的调查、评审角色 |
| 斜杠命令 | .claude/commands/*.md | 4 | 进入技能的薄入口 |
| 插件 | settings.json 的 enabledPlugins | 13 | 官方市场的工具 |
什么放进哪一层的标准也写在 README 里。关于钩子的那句最清楚:
放进钩子的标准只有一条 — 确实发生过,而且靠眼睛难以察觉。
一般的最佳实践不放。警告一多,就没人读了。
关于技能,放的是"重复过三次以上的流程",但"顺序一错就出事故的部分不放在正文,而是抽到 scripts/agent/ 的脚本里"。13 个插件中故意去掉了 commit-commands,因为 /commit 会在工作树里执行 git commit,而这在此仓库是被禁止的。
路径规则:只在打开那个文件时出现的指引
rules/ 的 6 个文件各有 paths:,只在触碰那些路径时才进入上下文。同一个道理:整本规则每次都读,就没人读。
app-js.md—backend/static/app.js的整文件 SHA-256 嵌在安全检查脚本里,"改一个字符部署就被拦,而且拦住的地方给出的信息驴唇不对马嘴(出现 streamSSE 相关字句)"。pipeline.md— "一 push,Jenkins 就每分钟轮询、无需审批直接上到生产(约 20 分钟)。部署只走 GitOps。"migrations.md— "已经应用的迁移绝对不改。"generated.md— 手改后会在下次生成时消失的文件及其生成器的表。curriculum.md— "评分器要双向测试 — 只看正确答案能通过只算一半。"tests.md— "在测试的文档字符串里写明为什么有这个测试、出过什么事故。"
测试监视着这些规则。tests/test_claude_harness.py 检查每个 paths: 是否匹配到真实文件,正文是否指回 AGENTS.md 或 scripts/。
钩子:拦四样,问两样
hooks/guard.py 在工具执行前从标准输入接收调用内容,从标准输出返回 deny 或 ask。里面恰好四样,每一样都来自真实事故。
GIT_STATE = re.compile(
r"\bgit\s+(?:stash|checkout|switch|restore|reset|rebase|merge|pull|clean)\b")
GIT_OK = re.compile(r"\bgit\s+(?:worktree|stash\s+list)\b")
KUBECTL_WRITE = re.compile(
r"\bkubectl\b[^|;&]*\s(?:apply|delete|patch|scale|edit|replace|annotate|label|cordon|drain)\b")
LOCKED = "backend/static/app.js"
- 改变工作树的 git 命令 deny。注释里有理由:"一次
git stash回退了另外四个人的编辑。" - 改变集群状态的 kubectl deny。只允许读取和临时 Pod。
- 不经过
push.sh的git push是 ask,信息里附上证据:"光是今天,那道门禁就拦下了四次 CI 失败。" - 编辑
app.js是 ask,因为忘了更新摘要,部署会悄无声息地被拦。
hooks/after_edit.py 在文件刚改完后只看那一个文件:Python 用 ast.parse,JavaScript 用 node --check,JSON 用 json.loads。全部测试超过 2 分钟,不可能每次编辑都跑,所以只抓"此刻这个文件自身是否成立",其余交给门禁。
钩子的危险在 README 的一句话里:"钩子会悄悄死掉 — 改完不跑测试,本想拦住的事故就照样发生。"所以 tests/test_claude_hooks.py 的 13 个测试把 JSON 直接喂给钩子,确认 deny、ask 和放行。
子代理:只读的角色
agents/ 里三个角色都没有写入工具。
| 名称 | 工具 | 角色 | 不做的事 |
|---|---|---|---|
| safe-researcher | Read, Grep, Glob, Bash | 读代码、文档、集群并总结 | Edit/Write、改 git 状态、kubectl 写操作 |
| grader-reviewer | Read, Grep, Glob, Bash | 双向评审评分器 | 不修改评分器,只给判定和反例 |
| deploy-watcher | Read, Bash | push 后盯着构建与生产反映 | kubectl 写操作、重试 |
grader-reviewer 定义文件里的一句话说明了这个角色为何存在:"'无条件通过的评分器'确实有好几个,那比没有还糟。"本文对 .claude/ 的调查也交给了 safe-researcher。既节省父会话的上下文,也堵死了调查中误改东西的路。
测试同样强制这一点:代理至少三个,tools 里没有 Edit、Write、NotebookEdit,描述里含有"不做"之类的否定表述。
流程写成脚本:push.sh 与 gate.sh
harness 的重心是 scripts/agent/ 下的 8 个脚本,其中两个是核心。
push.sh 是提交和 push 的唯一通道。它不在工作树里提交。它从 origin/main 新建一个 worktree,只把我点名的文件复制进去,在那里跑门禁,然后提交、push。若别人正在改同一个文件,就不整文件复制,而是通过 APPLY="脚本:路径" 传入一个只应用我的改动的脚本。门禁运行的 4 分钟里若 CI 推上了部署提交、origin 先行了,就把我的一次提交用 cherry-pick 重新放到新的 origin/main 上,最多三次。这个会话的日志里这种重试有 23 次。
worktree 路径每次运行都不同,注释里也有原因:"用固定路径时两次 push 重叠、互相删掉了对方的目录,那时的测试日志整个成了假的。"
gate.sh 在本地跑与 CI 的 Verify 阶段相同的东西:依次跑安全检查、课程检查、评分器审计,然后跑全部单元测试。做它的理由写在开头的注释里。
在构建 441~446 六次在同一处失败之后做的。每次 push 都在手挑测试来写,
改课程时没挑上 test_i18n_catalog。一挑就漏。所以全部跑,只在本地才有的
失败(未安装的依赖、没有 DB)用 gate_baseline.txt 比对过滤掉。
与基线的比对用 comm -13。以前是 diff | grep,可在 set -o pipefail 下 diff 的退出码让整条管道失败,于是找到了新失败却直接放过了。test_ko_source_is_current 就这样在构建 451~453 漏了三次。这个事故已用测试钉死:检查 gate.sh 用了 comm -13,且除 git diff 外不用任何 diff。
node 测试带着一个 300 秒的看门进程。node --test 没有默认时限,"activation.test.js 在 Mac 上卡住过两次,把整个 push 拖住"。
上锁的文件与部署的证据
bumpdigest.py 更新 app.js 的摘要,但更新时三件事一起做:把信任边界的数量(streamSSE、renderTrustedLessonMarkdown、innerHTML、eval( 等 11 种模式)与改动前的文件比对;有差异的必须用 ALLOW 明确批准;把说明改了什么、为什么改的 NOTE 附到历史块里。期望值不写死成数字,因为"曾在别人正当的改动之后误拦过"。五个测试把临时文件喂给这套判定逻辑:未批准的变化拒绝,同一历史写两次拒绝,空 NOTE 拒绝。
deploy_status.sh "不相信 ArgoCD 的 Synced 绿灯"。两个应用都是 selfHeal,画面永远是绿的,确实出现过三天前的旧镜像显示为 Synced。所以它分别读四样:Jenkins 最近几次构建的结果与耗时,gitops 的 newTag 与实际 Deployment 的镜像标签,ArgoCD 两个应用的状态,以及生产站点返回的 /app.js 是否与那次提交的文件 逐字节 相同。静态文件是部署的最后证据。
harness 测试自己
tests/test_claude_harness.py 的 21 个测试检查的不是代码,而是 harness 的结构。摘几条:
- 技能的 description 必须超过 80 字,含"使用",并说明 什么时候不用。
- 技能正文必须四节齐全:
## 为什么、## 流程、## 输出格式、## 不要做的事。 - 技能提到的
scripts/agent/*必须存在,.sh要有执行权限并通过bash -n,.py要通过compile()。 .claude/和scripts/agent/里任何地方都不能出现/Users/、/private/tmp/claude这类机器专属路径。push.sh里要有git worktree add、不能有git stash,git checkout必须与origin/main同在。- README 必须提到所有技能目录和
enabledPlugins里的所有插件,而commit-commands不能在其中。
多亏这些测试,harness 自引入以来结构没有变过。触碰 .claude/ 和 scripts/agent/ 的提交只有两次:引入的那次,和整理基线两行的那次。
留在会话之外的东西:记忆
如果仓库里的 harness 是"规则",那么仓库外留下的就是"经验"。这个工作环境的记忆目录里有 65 个文件:项目事实 53 个,用户反馈 9 个,外部引用 3 个。每个文件装一条事实和"为什么""如何应用",索引文件里的一行会在每次会话开始时被读入。
看几条这周写下的,就知道留下的是什么:"门禁读的是我本地副本的基线,不是 worktree 的""做出来和让人看见是两回事""手工做的 Job 容易丢掉 Pod 标签""CronJob 失败会把 ArgoCD 拖到下一次成功为止"。这些都没处写进代码,写进文档又会过时。
这个会话里实际经历的
有了 harness 事故照样发生,只是发生在 被拦住的地方。昨天为了把校验器改一行推上去,push 跑了四次。
push.sh已改成第一个参数接收提交信息 文件。给了字符串,就以"没有提交信息文件"结束。- 门禁把
test_production_cli_…_fails_unprovisioned当作新失败抓住。那是与我的改动无关的环境失败:这台 Mac 的 cryptography 50.0.1 与锁定的 50.0.0 不同,另一个错误先冒出来了。 - 同样的失败又被抓住。原因是 gate.sh 读的是 本地副本 的
gate_baseline.txt而不是 worktree 的,而本地副本过时、那个文件是空的。 - 对齐基线后通过了,其间 CI 又推上了部署提交,于是经过"origin 领先 — 重新放上去(1)"才 push 成功。
四次中没有一次到达生产。被门禁拦下的 41 次全是这类事。而第 3 条的教训变成了记忆文件,让下一个会话不再在同一处停下。
APPLY 脚本里也有一个坑。脚本会先确认"是否已经应用过",可那个判断的钥匙用了原文里也存在的字符串(resolve(demo.map(lang))),于是它说"已经有了",什么也没做。钥匙必须是只有新版本才含有的字符串。
局限
这套 harness 并未完成。文档自己就指出了两个局限。
没有人工审批环节。 docs/CI-CD.md 原话:"推到 main 的东西只要通过 Verify 和 dev E2E,同一个作业就照样一路上到生产。守住生产的只有那两道关口,所以关口上挂着什么才重要。"审批门禁只是作为 fail-closed 设计的目标写在那里,还没有运行。
基线因机器而异。 gate_baseline.txt 是过滤本地环境失败的文件,却放在仓库里,而实际上它必须一台机器一个样。上面第 3 条事故就来自这个矛盾。
而且滚动部署期间两个 Pod 返回的是不同构建。昨天浏览器把从旧 Pod 拿到的脚本留在缓存里,新功能一时看不见。harness 守住了提交和部署,还没守到浏览器缓存。
一句话
harness 不是多写规则,而是 把能在原地自动拦住真实发生过的事故的装置,一个一个地堆起来。规则放一处,装置放在工具调用、提交、部署的关口上,经验放在会话之外的记忆里。在这个仓库里这三层每天转几十次,结果就记录为被门禁拦下的 41 次。
현재 단락 (1/96)
用的是同一个模型,在某个仓库里代理会用 `git stash` 抹掉别人的编辑,在另一个仓库里这种事从不发生。差别不在模型,而在围绕模型的东西,也就是 **harness**:规则写在哪里,拦截哪些命...