一次改动复盘:自我改进系统里,「人批准了」必须是一份数据,而不是一句话。

1. 一个自相矛盾的任务⌗

MaxClaw 有一条自动修复流水线:框架识别出摩擦信号,分诊后派一个修复任务,在隔离 worktree 里改代码,最后调 ship 把分支送上落地通道。修复任务有硬边界——不碰密钥、data/、配置,不改依赖。依赖归 Max 管。

假设根因确实在一个第三方依赖里,比如虚构的 libdb@1.8.2 在某个协议状态下没有重置计数器。修复线程能做的是交还:relaxesConstraint: true 加一张卡片,请 Max 决定要不要放宽。Max 在卡片上点了「批准」。

然后下一次修复任务开工,它的提示词里同时出现了这样几段(改动前的原文):

<brief>  执行已获批准的依赖补丁……

   Dependency changes include vendor/, node_modules/, bun.lock and package.json outside scripts.
   A reproduced dependency bug requiring a dependency patch follows this same report path;
   do not patch or upgrade it here.
   ... An approval of a direction or an earlier commit does not
   remove the path restrictions enforced at ship; a still-forbidden edit must be handed back to Max.

Boundaries: only this repo; no secrets, data/, maxclaw.toml or dependency changes; ...

上面一句说「去打补丁」,下面三句说「不许打补丁,以前的批准也不算数」。就算模型冒险相信 brief,ship 的闸门 FORBIDDEN_FIX_PATHS 里 ^bun\.lock$、^vendor\// 照拦不误——闸门根本不知道有过批准。

结局只有两种:

模型的选择结果
相信边界,交还又一张卡片,Max 再批一次,下一个任务拿到同样矛盾的提示——循环
相信 brief,动手改完在 ship 被拒,白干

获批的修复永远开不了工。 问题不在哪句话写错了,而在批准和边界是两份互不相识的事实:批准活在 brief 的自然语言里,边界活在模板和闸门的常量里。

2. 常见的两种补法,以及为什么不够⌗

补法做法问题
改提示词brief 里写得更大声:「已获批准,边界不适用」闸门不读 brief;而且「边界不适用」比批准的范围大得多
开总开关任务带一个 allowDependencyChanges 标志,闸门看到就放行批准的是一个补丁,放开的是全部依赖;任务顺手升级个别的包也溜得过去

第一种让批准和闸门脱节,第二种让批准和范围脱节。要两头都对齐,批准必须是一份带精确范围的数据,任务文本和闸门都从这份数据派生。

3. 命名例外:一条记录⌗

新文件 src/improve/dependency-exception.ts 只定义了一个结构:

export interface DependencyException {
  dependency: string
  version: string
  /** Exact repo-relative files: package.json, bun.lock, vendor/…, patches/… only. */
  paths: string[]
  /** What the patch does, one line. */
  change: string
}

它在系统里的生命周期:

修复线程 A                         Max 的卡片                     修复线程 B
──────────                         ──────────                     ──────────
发现根因在依赖
improve_autofix_report({
  relaxesConstraint: true,
  card,
  dependencyException ─────────▶ 卡片正文多一行:
})                                「依赖补丁:libdb@1.8.2:…
     │                             (只允许改 X、Y、Z);
     │ 写入 needs-max 交接的         其余依赖改动仍禁止」
     │ evidence                   批准项效果:
     ▼                            「下一次修复只获准这一处依赖补丁」
improvement_actions                        │
                                           │ approve
                                           ▼
                          approvedDependencyException(signalIds)
                                    │                │
                        renderFixTask(…, exception)  ship(): 闸门放行
                        任务文本里的「命名例外」段    exception.paths
                        + 改写后的边界句              + package.json 只放该依赖条目

关键是右下角:任务文本和闸门调的是同一个函数、读的是同一条记录。文件头注释把这个意图写得很直接:This one record renders the task’s exception and boundaries and widens the ship gate by exactly those files, so approval and boundary cannot diverge.

4. 名字要精确到不能被放大⌗

一份例外只有在「范围比任何人想放大的都小」时才安全。checkDependencyException 在入口处校验一次:

const DEPENDENCY_FILE_PATHS = [/^package\.json$/, /^bun\.lock$/, /^vendor\/.+[^/]$/, /^patches\/.+[^/]$/]

function isExactDependencyFile(path: string): boolean {
  return !path.includes('..') && !path.includes('*') && !path.includes('\\')
    && DEPENDENCY_FILE_PATHS.some((re) => re.test(path))
}
  • 只能是依赖类文件:想借例外夹带一个 src/ 下的源文件,测试断言报 exact dependency files only。
  • 只能是具体文件:不能是目录(结尾 [^/])、不能 glob、不能 .. 跳出去。
  • 必须和 relaxesConstraint 一起出现:单独交一个 dependencyException,报 goes with relaxesConstraint。例外只能作为「请 Max 决定」的一部分诞生。

还有一个不起眼但很要紧的细节。report 的 evidence 是修复线程自由填写的字典,改动前是原样展开进交接记录的。新代码先把其中的 dependencyException 键剔掉,再写入校验过的那一份:

// the exception on record is only ever the one the card shows Max
const evidence = Object.fromEntries(Object.entries(input.evidence ?? {}).filter(([k]) => k !== 'dependencyException'))

测试专门构造了这个场景:正式参数命名 libdb 那一类补丁,evidence 里另塞一个「other@2.0.0,change: 'unshown'」。断言下一次任务文本不含 other@2.0.0。批准的必须恰好是 Max 在卡片上看见的那一份,卡片之外的任何东西都不会因为一次点击生效。

5. 什么才算「批准过」⌗

approvedDependencyException 的查询定义了批准的时序:

对每个信号:
  取最近一次转入 needs-max 的交接记录
  它是 relaxesConstraint 且带合法 dependencyException?  否 → 下一个信号
  在这次交接之后,有没有 Max 的 approve 决定?          有 → 返回这份例外
都没有 → null(任务文本与闸门完全照旧)

三条推论都有测试或任务文本覆盖:

情形结果
Max 选了「讨论」而不是「批准」下一个任务文本不含 Named exception(测试先走了这条)
批准发生在交接之前(旧批准)不算,created_at >= 交接时间
Max 批准了一张没有命名补丁的卡什么也不开;分诊提示里写明:修复线程要命名补丁再问一次

最后一条是故意的严格:笼统地批准「方向」不等于批准任何一个具体的依赖改动。这和既有的 approvedHold(Max 批准某个被卡住的具体 commit 后,下一个修复从它起步)是同一个模式——批准总是挂在一个具体对象上,再按时间对齐到交接之后。

6. 任务文本:同一份数据,两套措辞⌗

renderFixTask 多了一个参数 exception,有它和没它,边界那几段整段替换,而不是在原文后面追加一句「但是……」:

位置无例外有例外
新增段落—Named exception — the one dependency patch Max approved …,列出依赖@版本、改动、files (only these)
依赖规则Dependency changes include … / do not patch or upgrade it hereExcept for the named exception above, dependency changes are forbidden …
越界时走交还路径,并提示用 dependencyException 命名补丁超出例外的(别的文件、依赖、版本)同样交还并命名
批准与闸门An approval … does not remove the path restrictionsBeyond the named exception, an approval … does not remove …
最后的 Boundariesno … dependency changesno … dependency changes other than the named exception above

测试对这张表的每一格都做了正反两向断言:有例外时,旧的禁止句必须不在(not.toContain('do not patch or upgrade it here')),新的限定句必须在。追加一句「但是」会让矛盾依旧留在文本里,靠模型自己权衡哪句优先;整段替换之后,任务文本里没有任何一句话和批准冲突。

无例外分支也多了一行:交还时要带上 dependencyException { dependency, version, paths, change }。这让第一次交还就产出一份可批准的精确对象,而不是一张只能批准方向的卡。

7. 闸门:放行的恰好是那几个文件⌗

ship 里的改动只有两处,但都是精确收窄的:

const exception = await approvedDependencyException(this.deps.sql, signals.map((s) => s.id))
const forbidden = files.filter((f) => FORBIDDEN_FIX_PATHS.some((re) => re.test(f)) && !exception?.paths.includes(f))

路径用的是 includes——逐字匹配,不是模式。package.json 更细:它本来只允许改 scripts;有例外且例外列了 package.json 时,manifestWithoutDependency 先把该依赖自己的条目剥掉(dependencies 里的同名键、patchedDependencies 里 name@x 形式的键……),再比较改动前后是否完全相同。于是:

测试分支上的三次 ship:

① 打补丁 + 顺手把 other 从 1.0.0 升到 2.0.0
   → 拒:may only change package.json scripts and the entries of <dependency>
② 恢复 other,但新增 vendor/other.js
   → 拒:may not change vendor/other.js … his named exception covers only …
③ 删掉 vendor/other.js
   → 通过,files = [bun.lock, package.json, patches/<dependency>@<version>.patch]

拒绝消息里带着 covers only <paths>,修复线程被拒时能直接看到批准的边界在哪,而不是只看到一句「依赖归 Max」。

8. 为什么这比「写进提示」更对⌗

把这次改动抽象一下,其实是一条原则:在自我改进系统里,人的批准是一个会被机器消费的对象,它要能同时被两类读者读懂——读自然语言的模型,和读数据的闸门。

  • 只写给模型(提示词):闸门不认,批准无效;模型面对两段相反的指令,表现取决于它当下更信哪段。
  • 只写给闸门(开关):模型不知道范围,闸门的范围又往往比批准宽。
  • 从一份结构化记录同时派生两者:模型读到的边界和闸门执行的边界逐字对应,批准的范围就是卡片上 Max 看到的那一行。

还有一个反方向的保证同样重要:没有获批的命名例外时,任务文本和闸门一个字都不变。测试的第一段就断言了这一点——旧的禁止句原样都在。例外是加法,且只加它自己。

9. 付出的代价⌗

代价说明
多一次往返第一次交还若没命名补丁,批准什么也不开,要再问一次
每次只开一处同一组信号只取第一份获批的例外;需要两个依赖补丁就要分别命名、分别批准
不认通配patches/ 下要列出每个文件;补丁实际改了没列出的文件,ship 会拒
条目按名字识别package.json 里只认键名等于依赖名或以 <name>@ 开头的条目;别的写法的字段改动仍算越界
只覆盖依赖其他边界(配置、数据、密钥)仍走原有的交还路径,没有命名例外

这些代价都朝同一个方向:宁可多问一次,也不让一次点击放开比卡片上更多的东西。对一个会自己改自己的系统来说,这个方向是对的——它最怕的不是修得慢,而是「批准过」三个字悄悄变得比当初的意思更大。