开源 Agent 套件 ECC 的钩子运行时:强制与建议的分界线在哪

2026-07-29

本文基于 ECC 仓库 commit 591ab5c(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/affaan-m/ECC 最新代码与文档为准。

钩子之所以能”强制”,唯一的原因是它跑在模型之外——它是 harness 在工具调用边界上拉起的一个进程,模型没有跳过它的权限;而写在规则文件里的约束,模型永远保留不遵守的自由。 ECC(MIT 许可证,仓库地址 https://github.com/affaan-m/ECC )里 agents 目录有 67 个 agent、skills 目录有 281 个技能、commands 目录有 94 个命令,这些都是提示层的东西,模型可以照做也可以漂移;只有 hooks/ 这一层是执行层。搞清楚这条分界线,你才知道这套东西装上之后到底改变了什么。

站内已经有两篇讲通用方法论的文章:钩子机制本身怎么用讲事件与写法,Agent 的验收标准怎么定讲你该验什么。本篇不重复那两件事,它盯的是一个具体项目——ECC 把这些方法论落成了什么形状的文件、什么样的开关、以及付出了哪些代价。

一、钩子挂在哪些时机上

ECC 的可执行钩子图是单文件 hooks/hooks.json。打开它,实际用到的事件有七个:PreToolUsePostToolUsePostToolUseFailureStopPreCompactSessionStartSessionEnd

按仓库 hooks/README.md 的说法,触发顺序是这样一条线:用户提出请求,模型挑选工具,PreToolUse 钩子先跑,工具执行,PostToolUse 钩子后跑。Stop 在模型每次回复之后触发,SessionStartSessionEnd 卡在会话的两端,PreCompact 卡在上下文压缩之前。

这里有两个容易被忽略的时机安排,值得单独说。

一个是 Stop。它不是”会话结束”,而是”每一次回复结束”。ECC 把批量格式化与类型检查放在了 stop:format-typecheck 这条 Stop 钩子上,配置里的说明写得很直白:把本次回复里改过的 JS/TS 文件一次性格式化和 tsc 检查,而不是每次 Edit 之后各跑一遍。这是一个明确的性能取舍——你不会在连改十个文件时被十次 tsc 拖住,代价是错误反馈来得晚一拍。

另一个是 PreCompact。上下文压缩这件事对长会话是刚需,但压缩会丢东西。ECC 在压缩之前挂了 pre:compact 保存状态,配合 SessionStart 时把上一轮的上下文读回来。这套东西在 hooks/memory-persistence/ 目录下有一份独立文档,把生命周期契约列成了表:哪个事件、哪个钩子 ID、干什么、是否阻塞。如果你关心的是长会话里状态怎么不丢,可以顺带看看上下文管理的一般做法再回来对照。

PostToolUseFailure 这个事件比较少见,ECC 用它跑 MCP 健康检查:工具调用失败之后记账、把 server 标记为不健康、尝试重连;对应的 PreToolUse 侧还有一条同名脚本的钩子,在调用前拦掉已知不健康的 MCP 调用。

二、配置长什么样

schemas/hooks.schema.json 是这套配置的形状约束,读它比读示例更省事。

顶层允许两种形态:一个带 hooks 字段的对象,或者直接是一个 matcher 条目的数组。事件名走的是白名单 propertyNames.enum,里面列了十八个事件——除了上面用到的七个,还有 UserPromptSubmitPermissionRequestNotificationSubagentStartSubagentStopInstructionsLoadedTeammateIdleTaskCompletedConfigChangeWorktreeCreateWorktreeRemove。也就是说,schema 认的事件面比 ECC 当前用到的宽不少,剩下那些是留白。

每个 matcher 条目必须有 hooks 数组,matcher 可以是字符串也可以是对象,另外可以带 description。ECC 在 hooks.json 里还给每条挂了 id,比如 pre:bash:dispatcherstop:format-typecheckpre:edit-write:gateguard-fact-force。schema 没有把 id 写进属性表,但也没有禁止额外字段,所以这是 ECC 自己加的约定——后面你会看到,这个 ID 就是运行时开关的抓取点。

hooks 数组里的每一项走 oneOf,有三种形态:

  • type: "command",必须带 command(字符串或字符串数组),可选 asynctimeout
  • type: "http",必须带 url,可选 headersallowedEnvVarstimeout
  • type: "prompt""agent",必须带 prompt,可选 modeltimeout

ECC 当前所有钩子都是 command 形态,一条 http 或 prompt 钩子都没用。这是个能看出设计取向的细节:http 钩子意味着把工具调用信息发到外部服务,prompt 钩子意味着再拉一次模型推理来做判断。ECC 全部选了本地进程——判定逻辑是确定性的 Node 代码,同样的输入给同样的结果,不受采样波动影响,也不额外产生模型调用。这和 hooks/memory-persistence/README.md 里写的运维期望是一致的:持久化默认留在本地,除非用户明确启用某个集成,否则不要把 transcript 或工具轨迹送到托管服务。

异步钩子的写法在 hooks/README.md 里有现成的:

{
  "type": "command",
  "command": "node my-slow-hook.js",
  "async": true,
  "timeout": 30
}

文档同时点明了一句关键限制:异步钩子在后台跑,不能阻塞工具执行。所以凡是要拦的东西,必须是同步的。ECC 的 PostToolUse 就是照这条线切开的——post:dispatcher:syncpost:dispatcher:async 两个入口分别对应同一个调度脚本里的两张钩子表,同步那张里放的是要在本轮就给出结论的检查,异步那张里放的是能拖到后台慢慢跑的分析。既然后置钩子本来就阻断不了工具,把慢活全部推进异步表几乎没有代价,这是这套切分成立的前提。

三、它凭什么”强制”

三层机制叠在一起,才让约束真的落地。

第一层是退出码。 hooks/README.md 写死了语义:0 表示成功继续,2 表示阻断这次工具调用(仅 PreToolUse 有效),其他非零表示钩子自身出错,会被记录但不阻断。PostToolUse 可以分析输出,但不能阻断——工具已经跑完了,拦不住。

第二层是钩子干的事本身。 举两个 ECC 里的具体例子。

scripts/hooks/config-protection.js 拦的是”改配置让检查通过”。它维护一份受保护文件名清单,覆盖 ESLint 各种变体、Prettier、Biome、Ruff、stylelint、markdownlint、.shellcheckrc;命中已存在的配置文件被修改就退出 2。文件头部的注释把动机写得很清楚:Agent 经常改这些配置来让检查通过,而不是去修真正的代码,这个钩子把它推回去修源码。同一个文件里还留了一条克制的例外说明——pyproject.toml 故意不进清单,因为它混着项目元信息和 linter 配置,全拦会挡掉正常的依赖变更。这种”知道自己会误伤,所以主动收窄”的判断,比清单本身更值得学。

scripts/hooks/gateguard-fact-force.js 更狠一点。它在每个文件的第一次 Edit/Write/MultiEdit 时直接拦下,要求先给出事实:谁 import 了这个文件、受影响的公开 API 是什么、数据结构怎么定义的、用户的原话是什么。脚本注释里给了理由:与其问”你确定吗”(模型永远回答确定),不如逼它去查——查这个动作本身产生的认知,是自我评估给不了的。对 Bash 的破坏性命令另有一套门槛,要求列出目标和回滚方案。这个能力在仓库里标注了上游来源(github.com/zunoworks/gateguard),并非 ECC 原创,ECC 做的是把它接进钩子图并加上 profile 门控。

第三层是它跑在模型之外。 仓库里的 rules/common/hooks.md 是提示层文档,写着”审慎使用自动接受权限""不要用 dangerously-skip-permissions 标志”——这些是给模型看的建议。而 hooks.json 里那些 node -e "..." 是 harness 直接拉起的进程。前者可以被忽略,后者不能。这就是本文开头那句话的全部含义。

想再往下想一层的话,这套机制和权限模型该怎么设计是同一个问题的两面:钩子是把”不许做什么”从人的注意力里搬到进程边界上。

四、开关、降级与逃生门

一套会拦你的东西,必须能关。ECC 的开关分三级。

profile 级。 scripts/lib/hook-flags.js 定义了三个 profile:minimalstandardstrict,默认 standard。判定函数很短:

function isHookEnabled(hookId, options = {}) {
  const id = normalizeId(hookId);
  if (!id) return true;

  const disabled = getDisabledHookIds();
  if (disabled.has(id)) {
    return false;
  }

  const profile = getHookProfile();
  const allowedProfiles = parseProfiles(options.profiles);
  return allowedProfiles.includes(profile);
}

hooks.json 里凡是经 run-with-flags.js 转一道的钩子,命令行末尾都跟着一串允许的 profile,比如 standard,strictminimal,standard,strict;几个自己带调度逻辑的入口(pre:bash:dispatcherpost:dispatcher:syncpost:dispatcher:asyncsession:start)不走这条命令行参数,profile 写在它们各自代码里的钩子表上。分档的规律很清楚:生命周期类的(session-end、cost-tracker、evaluate-session、会话结束标记)挂 minimal,standard,strict 三档全开,质量检查类的(format-typecheck、check-console-log、config-protection、GateGuard、doc-file-warning)一律 standard,strict,也就是降到 minimal 就全部让路。这个分法本身就是设计意图的自白——它认为”记录发生了什么”是任何档位都该做的,“拦住你”则是可选的。

单条级。 ECC_DISABLED_HOOKS 收一个逗号分隔的钩子 ID 列表,hooks/README.md 给的示例是 "pre:bash:tmux-reminder,post:edit:typecheck"。这就是前面说的 id 字段的用处。另外 GateGuard 有独立开关 ECC_GATEGUARD=off,方便在初始安装或救火时单独放行。

README 里推荐的运行时控制方式是环境变量,而不是改 hooks.json

# minimal | standard | strict (default: standard)
export ECC_HOOK_PROFILE=standard

# Disable specific hook IDs (comma-separated)
export ECC_DISABLED_HOOKS="pre:bash:tmux-reminder,post:edit:typecheck"

# Disable only GateGuard during setup or recovery
export ECC_GATEGUARD=off

观察级。 ECC_DRY_RUN=1 会让 run-with-flags.js 只打印一行预览——哪个钩子本来会执行哪个脚本、profile 是什么、目标文件或命令是什么——然后放行。想知道装上之后到底有多少条会在你的工作流里被触发,这是唯一不用冒险的办法。

失败路径的处理也值得看。scripts/hooks/run-with-flags.js 里几处防御都留了注释:脚本路径必须落在插件根目录之内,否则拒绝执行;stdin 超限时不回传被截断的 JSON,因为半截 JSON 会被 harness 判成钩子失败进而挡住工具调用,改成空 stdout 加退出 0,语义是”没意见”,失败方向朝开放走;退出前要等 stdout 排空再退,否则超过管道缓冲的输出会被截掉。这几处都是被真实问题打过的地方,不是凭空加的防御。

五、结构地图

组成部分它负责什么仓库位置你什么时候会碰到它
可执行钩子图事件、matcher、命令、ID 的唯一真源hooks/hooks.json想知道到底挂了哪些钩子时
配置 schema约束事件白名单与三种钩子形态schemas/hooks.schema.json自己写钩子、或校验配置合法性时
开关闸门与执行器判定是否启用、dry-run、路径校验、调用脚本scripts/hooks/run-with-flags.js排查”钩子为什么没生效”时
profile 与禁用名单解析环境变量,给出启用与否scripts/lib/hook-flags.js想搞清楚三档 profile 各开了什么时
PostToolUse 合并调度把多条后置钩子拆成同步/异步两个入口scripts/hooks/posttooluse-dispatcher.js后置检查变慢、想知道谁在跑时
Bash 前置调度合并 Bash 的质量、tmux、push、GateGuard 检查scripts/hooks/pre-bash-dispatcher.js某条 Bash 命令被拦下时
事实闸首次改文件前逼出调查结论scripts/hooks/gateguard-fact-force.js第一次编辑被拦、不知道为什么时
配置保护拦住”改 lint 配置让检查过”scripts/hooks/config-protection.js改 eslint/prettier/biome 配置被拦时
生命周期契约记忆持久化的人读版定义hooks/memory-persistence/README.md关心会话状态怎么存、存哪时

顺带说一句合并调度的动机。posttooluse-dispatcher.js 里把钩子分成 SYNC_HOOKSASYNC_HOOKS 两个数组,每一项带着自己的 ID、matcher、profile 和实现函数,两个数组各由一个进程跑完。run-with-flags.js 里还有一条更细的优化:如果目标脚本导出了 run() 函数,就直接 require 进当前进程调用,省掉一次 Node 进程启动;没导出的走老路 spawnSync 起子进程。注释里也标明了这么做的安全前提——只有导出 run() 的脚本才能被 require,因为老式脚本在模块作用域直接注册 stdin 监听和调 process.exit,require 进来会干扰父进程或者跑两遍。

Bash 那一侧的结构略有不同:pre-bash-dispatcher.js 本身只有二十来行,负责收 stdin 然后转交,真正的钩子表和启用判定在它 require 的 bash-hook-dispatcher.js 里。所以排查 Bash 被拦时,看第一个文件只能确认调用链,得往下再翻一层才看得到判定条件。这种”入口文件极薄、逻辑在下一层”的写法在 ECC 的钩子目录里出现多次,读代码时要有心理准备。

六、边界与代价

这套设计放弃了一些东西,说清楚比夸它有用。

它不做语义判断。 所有判定都是路径匹配、文件名清单、正则。config-protection.js 认的是文件名,你把 lint 规则塞进一个不在清单里的文件,它就看不见。check-console-log 认的是文本模式,不认代码含义。这是选择确定性的代价——想要语义判断,就得引入模型调用,那又回到”结果会飘”和”额外成本”这两个问题上。

它拦不住 PostToolUse 之后的事。 后置钩子只能报告,不能回滚。文件已经写了、命令已经跑了。真正的护栏只有 PreToolUse 那一道,其余全是事后审计。

它高度依赖 Node 环境和插件根目录解析。 hooks.json 里每条命令都内联了一段根目录探测逻辑:先看 CLAUDE_PLUGIN_ROOT,再试 ~/.claude,再依次试若干插件安装路径,最后扫插件缓存目录。这段逻辑在每条钩子里各复制了一份。好处是任何一条钩子都能独立自举,坏处是 hooks.json 的可读性基本没有了,改一处得改一片。

它会往你的机器上写东西。 GateGuard 的会话状态默认落在用户主目录下的一个隐藏目录里(可以用 GATEGUARD_STATE_DIR 改);SessionStart 会把上一轮上下文读进来,Stop 会持久化会话状态、评估可提取的模式、记录运行成本标记。这些都是本地文件,但确实是持续写入。装之前你应该知道这一点,而不是事后发现。

MCP 健康检查会主动重连。 这意味着钩子层会去碰外部服务。如果你的 MCP server 连着生产系统,这个行为需要你自己评估。

它明确不管的事: 不管你的代码对不对,不管任务做完没有,不管模型的方案是否合理。它管的只有”这次工具调用允不允许发生”和”发生之后要不要记一笔”。把它当成质量保证会失望——它是流程约束,不是评审。真正的验收还得靠另一套判据

七、上手与避坑清单

别把仓库里的 hooks.json 直接粘进你的 settings。 hooks/README.md 专门写了这条:checked-in 的这份文件是面向仓库/插件的,里面的路径是相对插件根目录的,直接复制到 ~/.claude/hooks/hooks.json 或者粘进 ~/.claude/settings.json 会因为路径解析不到而失效。会踩是因为这个文件看起来就是一份现成配置。正确做法是走安装器(install.sh --target claude --modules hooks-runtime,Windows 用 install.ps1),由它把命令重写成针对你实际 Claude 根目录的形式。

先 dry-run,再放开。 会踩是因为默认 profile 是 standard,而 hooks.json 里挂着的二十来个条目在这一档下几乎全部启用,其中还有几个条目本身是调度器,进去之后又各自展开成一组子钩子——真实在跑的数量比你数条目得到的还多,里面就包括会退出 2 拦你的那几条。第一次在真实项目上遇到 GateGuard 拦截时很容易误判成工具坏了。避法是先设 ECC_DRY_RUN=1 跑一天,把预览行看一遍,心里有数了再放开。

关钩子用 ID,别改文件。 会踩是因为直觉上”不想要就删掉”,但删了 hooks.json 的条目,下次安装或更新插件就被覆盖回来了。用 ECC_DISABLED_HOOKS 写 ID,或者把 profile 降到 minimal,这些是环境变量,不会被覆盖。ID 就写在 hooks.json 每个条目的 id 字段里,照抄即可。

Windows 上注意环境变量的设置方式。 会踩是因为文档里的示例大多是 export。README 给了 PowerShell 的写法([Environment]::SetEnvironmentVariable),Claude 配置根目录在 %USERPROFILE%\.claude。这类跨平台差异在钩子这种”跑外部进程”的场景里特别容易出事。

自己写钩子时,记得把原始 stdin 原样输出到 stdout。 README 的示例里最后一行是 console.log(data),注释写着”始终把原始数据输出到 stdout”。会踩是因为写惯了普通脚本,只顾着 console.error 报警告忘了透传,结果下一环拿到空输入。警告走 stderr,阻断走退出码 2,数据走 stdout——三条通道各管一件事,别混。

Stop 钩子里的重活要算清时间。 stop:format-typecheck 挂的是 tsc,配置里给了明显偏长的超时。会踩是因为大仓库的一次全量类型检查会让你每次回复后都干等。避法是在大仓库里把这条单独禁掉,改用你自己的 CI 或者编辑器常驻检查。相关的成本感知可以参考可观察日志该记什么那篇的思路。

收束

判断这套钩子层值不值得装,用三个问题就够了:

其一,你手上有没有”模型反复犯、口头提醒不管用”的具体错误?如果有,钩子是对症的;如果只是想让输出更好,它帮不上。

其二,你能不能接受一个会在你机器上写状态、会主动重连 MCP、会在第一次编辑文件时把你拦下的东西?这些代价是明写在代码里的,不是隐藏成本,但你得认。

其三,你愿不愿意读代码?这套东西的行为边界全在 scripts/hooks/ 下的那几十个脚本里,文档只是索引。

真要动手,读文件的顺序建议是:hooks/hooks.json 看全貌,scripts/lib/hook-flags.js 看开关怎么算,scripts/hooks/run-with-flags.js 看一条钩子从触发到退出的完整路径,最后挑一个你最在意的具体钩子——比如 config-protection.js——读到底。四个文件读完,你就能自己判断哪几条留、哪几条关了。

本文属于 ECC 开源 Agent 套件专题(共 40 篇,从选择性安装一直拆到内部机制)。同一批还写了另一种取向的项目——不给你资产、只给你纪律的 superpowers 方法论专题

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。