Agent 方法论框架 superpowers 的会话钩子注入了什么

2026-07-29

本文基于 superpowers 仓库 commit 44c9b2d(2026-07-27)梳理,该项目仍在持续迭代,具体行为以仓库 https://github.com/obra/superpowers 最新代码与文档为准。

钩子在这里不负责”管住”模型,它只负责保证一份文件每次都出现在会话开头。 superpowers(MIT 许可证,https://github.com/obra/superpowers )挂的钩子从头到尾只做一件事:在会话起点把 skills/using-superpowers/SKILL.md 的全文读出来,包一层标记,转义成 JSON 打到标准输出,交给宿主注入上下文。约束力来自那份文件里写的话,钩子提供的只是”它一定在场”这个前提。把这两件事分开看,后面所有设计取舍才讲得通。

一、为什么规则要从配置文件挪到钩子里

你大概遇到过这种情况:在 CLAUDE.md 里写了一条”动手前先检查有没有可用的技能”,第一轮模型照做,第三轮就当没看见。项目级配置文件是”可能被读到”的材料,它和用户消息、历史对话挤在同一片上下文里,越往后权重越模糊。

superpowers 的处理方式是把这条规则的投递从”写进配置文件等模型自己看”换成”每次会话开始由宿主执行一段程序把它送进去”。变的不是措辞强度,是投递时机与确定性——只要钩子跑起来了,这段文本一定在第一轮就位;/clear 之后在,上下文压缩之后也在。

被送进去的那份 SKILL.md 才是”强制”的载体。它的正文是一张红旗表格,把模型可能给自己找的借口逐条列出来再驳回:想着”这只是个简单问题”、“我先看看代码库”、“我记得这个技能内容”,对应的回应都是先调技能。它还明确了顺序——先调用技能再回应,包括在提澄清问题之前;开头的 <SUBAGENT-STOP> 又留了出口,被派去执行具体任务的 subagent 可以忽略这条规则。

站内 Claude Code 钩子机制 讲的是钩子这个能力本身有哪些事件、能拦在什么位置,Agent 验收标准怎么定 讲的是通用方法论层面怎么给 Agent 划验收线。这两篇是”应该怎么想”,本篇是”一个具体的开源项目把它落在了哪几个文件里、每个字段写了什么、换台机器会从哪儿断”。想先补技能这个概念本身,可以看 Claude Code Skills 是什么

二、钩子实际做了哪四步

Claude Code 侧的注册文件很短,hooks/hooks.json 全文如下:

{
  "hooks": {
    "SessionStart": [
      {
        "matcher": "startup|clear|compact",
        "hooks": [
          {
            "type": "command",
            "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook.cmd\" session-start",
            "shell": "bash",
            "async": false
          }
        ]
      }
    ]
  }
}

几个字段都不是随手写的。matcher 里的三个词决定了触发场合:新开会话、清空会话、上下文压缩之后,都要重新注入一次。命令里的路径带引号,docs/windows/polyglot-hooks.md 给的理由是 ${CLAUDE_PLUGIN_ROOT} 可能含空格。"shell": "bash" 这一项的理由写得更细:Claude Code 在 Windows 上没装 Git Bash 时会走 PowerShell(更早的版本走 CMD.exe),而这两种都解析不了这条命令串——PowerShell 把开头那段带引号的路径当成字符串表达式,遇到后面的裸词就报错;CMD.exe 的 /c 引号规则在路径含 ( 这类元字符时会把外层引号剥掉。声明 shell 强制走 Git Bash,装了就正常跑,没装则给出一条可操作的报错,而不是一段看不懂的语法错误。文档同时注明这个键是较新版本的 Claude Code 才认的,更早的版本会直接忽略它——也就是说这层保护在旧版上等于不存在,排查时别默认它一定生效。

命令指到的 hooks/run-hook.cmd 只是个派发器,真正的逻辑在 hooks/session-start 里,四步:

  1. 从脚本自身位置反推插件根目录,catskills/using-superpowers/SKILL.md;读不到时用一句 Error reading using-superpowers skill 兜底,不让整个钩子崩掉。
  2. escape_for_json 转义。这个函数是纯 bash 参数替换,反斜杠、双引号、换行、回车、制表符各替一遍。脚本注释解释了为什么这么写:每个 ${s//old/new} 是一次 C 层扫描,比它替换掉的那个逐字符循环快若干量级。
  3. 拼成 <EXTREMELY_IMPORTANT> 包裹的字符串,前面加一句”你有超能力”以及”下面是 using-superpowers 技能全文,其他技能请用 Skill 工具”。注意这里是把 SKILL.md 原样塞进去的,YAML frontmatter 也一起进——docs/porting-to-a-new-harness.md 明确说这是可以接受的。
  4. 按环境变量判断当前宿主,printf 出对应形状的 JSON,然后 exit 0

各部分的分工列一下,方便你出问题时知道该翻哪个文件:

组成部分它负责什么对应仓库位置你什么时候会碰到它
Claude Code 事件注册声明 SessionStart、matcher 三态、强制 shell 为 bashhooks/hooks.json钩子完全不触发时
Cursor 事件注册version: 1 + 小写 sessionStart + 相对路径命令,不带 matcher/type/asynchooks/hooks-cursor.json在 Cursor 里装这个插件
Cursor 插件清单"hooks": "./hooks/hooks-cursor.json" 指向上面那份.cursor-plugin/plugin.json换宿主装插件
跨平台派发脚本同一个文件被 cmd 与 bash 各自解析,找到 bash 后执行同目录下的无扩展名脚本hooks/run-hook.cmdWindows 上”装了没反应”
钩子正文读文件、转义、按宿主选输出形状hooks/session-start注入内容不对或重复注入
被注入的内容本体技能调用规则、红旗表格、优先级说明skills/using-superpowers/SKILL.md想改”到底注入了什么”
回归测试断言三种输出形状互斥、断言 hooks.json 里 shell 必须是 bashtests/hooks/test-session-start.sh改完钩子想验证
行尾约定.cmdsession-start 钉成 LF.gitattributes源码在 Windows 上被改过行尾

三、一个文件被两种解释器同时读

hooks/run-hook.cmd 是这套设计里最像杂技的一块。它的第一行是:

: << 'CMDBLOCK'

bash 读到的是:对 :(空操作)开一个 heredoc,于是底下整段批处理命令被当成 heredoc 内容吞掉,什么也不执行;走到 CMDBLOCK 结束标记之后,才开始跑真正的 Unix 分支——解析脚本所在目录,然后 exec bash 执行传进来的那个脚本名。

cmd.exe 读到的是另一回事:它看不懂第一行,但会继续往下逐条执行批处理命令,走到 exit /b 就停住,永远到不了下面的 Unix 部分。批处理分支干的事很直白,按顺序试三个位置找 bash:

if exist "C:\Program Files\Git\bin\bash.exe" (
    "C:\Program Files\Git\bin\bash.exe" "%HOOK_DIR%%~1" %2 %3 %4 %5 %6 %7 %8 %9
    exit /b %ERRORLEVEL%
)

第二处是 C:\Program Files (x86)\Git\bin\bash.exe,第三处是 where bash 命中的 PATH 上的 bash(MSYS2、Cygwin 或非默认路径的 Git 安装都算)。三处都没有,就 exit /b 0 静默退出,注释写明这是有意的:插件本身继续可用,只是跳过这次上下文注入。

还有一个细节容易被忽略:钩子脚本一律不带扩展名,是 session-start 而不是 session-start.sh。文档给的原因是 Claude Code 在 Windows 上会给任何路径里含 .sh 的命令自动前置 bash,那会打乱这个派发器的调用链。同理,批处理分支用 %2%9 逐个透传参数,位置参数的数量上限就写在字面上。

设计取舍上这份文档还留了两条说明:不用登录 shell(-l),因为钩子脚本应当自足、不依赖登录期的 PATH 设置;不用 cygpath 做路径转换,因为 bash 直接收 Windows 路径就能处理,那是旧的 -c "..." 调用方式才需要的东西。这两条也反过来约束了你写钩子逻辑的方式:别依赖 PATH 上的外部工具,能用 bash 内建就用内建。文档里那段不调用任何外部命令的 JSON 转义示例,就是这条约束的产物。

四、三种宿主,三种 JSON,只能发一种

hooks/session-start 末尾的分支是整个脚本里最需要逐字读的地方:

if [ -n "${CURSOR_PLUGIN_ROOT:-}" ]; then
  printf '{\n  "additional_context": "%s"\n}\n' "$session_context" | cat
elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] && [ -z "${COPILOT_CLI:-}" ]; then
  printf '{\n  "hookSpecificOutput": {\n    "hookEventName": "SessionStart",\n    "additionalContext": "%s"\n  }\n}\n' "$session_context" | cat
else
  printf '{\n  "additionalContext": "%s"\n}\n' "$session_context" | cat
fi

三种形状对应三类宿主:Cursor 要顶层的蛇形 additional_context;Claude Code 要嵌套在 hookSpecificOutput 里、并且带上 hookEventName;Copilot CLI 和其他遵循 SDK 标准的宿主要顶层驼峰 additionalContext

关键在脚本注释里那句话:Claude Code 会同时读 additional_contexthookSpecificOutput,而且不做去重,所以只能发当前平台真正消费的那一个字段。发多了就是双重注入,同一份内容在上下文里出现两遍。分支顺序也不是随意排的——Cursor 可能同时设置 CLAUDE_PLUGIN_ROOT,所以它的判断必须排在最前面,否则会被后面那条吃掉;Copilot CLI 反过来是靠 COPILOT_CLI 这个额外变量从 Claude Code 分支里被排除出去的。docs/porting-to-a-new-harness.md 把这一整块直接称为陷阱,并给了新宿主接入时的排查办法:先注册一个只打印环境变量的临时钩子,观察哪个变量能标识宿主、宿主又怎么消费你的标准输出,把这三件事(环境变量、JSON 字段与嵌套、事件匹配串)钉死了再动手写分支。

这些约束被固化成了测试。tests/hooks/test-session-start.sh 对每种形状都做互斥断言:嵌套形状里若出现任何顶层上下文字段,判失败;Cursor 形状里若出现 hookSpecificOutput,判失败;SDK 形状里若混进蛇形字段,同样判失败。另有一条断言直接读 hooks/hooks.json,检查 shell 是不是 "bash"、命令串是不是以 run-hook.cmd" session-start 结尾。测试还用 env -i 清空环境再逐个塞变量,避免开发机上的现有变量把结果带偏。

顺带一个能省你半小时的实现细节:这段输出用 printf 而不是 heredoc,脚本注释指向仓库里对应的那个 issue,发布说明把原因记成:macOS 上用 Homebrew 装的较新 bash 存在一个回归,在 heredoc 里展开大变量时会无限挂起。这类问题的坏处在于它跟平台和 bash 来源绑死,本机不复现就基本想不到,只能靠别人踩过的记录避开。如果你自己写钩子要吐一大坨 JSON,避开 heredoc 这条路。想系统地理解这类注入对上下文的占用,可以配合 Agent 上下文管理 一起看。

五、边界与代价:它明确不管的事

它只挂了会话起点一个事件。 整份 hooks.json 里只有 SessionStart,没有工具调用前后的拦截。这意味着钩子不能阻止模型跳过技能,它只能保证规则文本在场。真正让模型照做的是 SKILL.md 里那些措辞,属于”靠遵从”而不是”靠拦截”。你如果期待的是程序级强制,这套设计给不了。

每次会话开头都要付一次固定的上下文开销。 新开、清空、压缩之后各注入一遍,而且是整份文件原样进,包括 YAML frontmatter。文件不长,但这笔开销是恒定的、无法按需裁剪的。

找不到 bash 时静默失败。 exit /b 0 是明写在注释里的取舍:不打断没装 Git for Windows 的用户。代价是你收不到任何提示,表现为”插件装了但模型完全不提技能”。这条取舍对不对取决于你的团队——如果注入是流程的硬前提,静默跳过反而更危险。

这套方法论本身会让开发变慢。 SKILL.md 的规则是哪怕只有百分之一的可能相关也必须先调技能,而且要在提澄清问题之前调。改一个常量、调一行文案这种事走完整套流程就是过度设计,模型的输出也会明显变长变啰嗦。项目自己留了两个出口:文件末尾写明用户指令(CLAUDE.md、AGENTS.md 一类,以及直接要求)优先级高于技能,只有在人明确说了跳过时才跳过;开头的 <SUBAGENT-STOP> 让被派去执行具体任务的 subagent 直接忽略这条规则。这两个出口就是维护者自己承认的代价边界。

它不管的还有: 技能内容写得好不好、你项目的编码约定、以及产出验收。钩子只解决投递,不解决内容质量。

六、上手与避坑清单

钩子完全不触发。 会踩是因为事件名和匹配串是宿主的契约,不是这个项目定的——Claude Code 用 startup|clear|compact,Cursor 用 sessionStart,两份配置文件的字段集合也不一样(Cursor 那份没有 matchertypeasync)。照着错误的那份改,钩子会安静地永不触发。怎么避:先确认你的宿主对应 hooks/hooks.json 还是 hooks/hooks-cursor.json,再逐字核对匹配串。

Windows 上”装了没反应”。 会踩是因为三个 bash 位置全落空时派发器返回 0,命令行不会有任何错误。怎么避:手动跑一次 bash hooks/run-hook.cmd session-start,看有没有 JSON 打出来;没有就把 Git for Windows 装到默认路径,或者确保 bash 在 PATH 上。Windows 环境本身的坑可以顺带看 Claude Code Windows 安装问题

给钩子脚本加了 .sh 后缀。 会踩是因为改名看着无害,但 Claude Code 在 Windows 上会给含 .sh 的命令自动前置 bash,派发链被绕开,或者干脆去找一个不存在的 session-start.sh。怎么避:保持无扩展名,这是仓库里明写的刻意选择。

源码行尾被换成了 CRLF。 会踩是因为这个文件要同时被 cmd 和 bash 解析,仓库特意用 .gitattributes.cmdsession-start 钉成 LF,注释写明了理由。如果你的取源方式绕过了 gitattributes(打包下载后再处理、编辑器自动转换),这层保护就没了。怎么避:改完脚本提交前确认行尾没被编辑器改掉。

自己加平台分支时随手往末尾加。 会踩是因为有的宿主会顺带设置 CLAUDE_PLUGIN_ROOT,新分支排在后面就被前面那条遮蔽了——Cursor 正是这种情况,所以它的判断被排在第一。怎么避:新分支放在会遮蔽它的分支之前,并补一条形状互斥的断言。

图省事同时输出两种字段。 会踩是因为”多发一个总没坏处”这个直觉在这里是错的:Claude Code 两个都读且不去重,结果是同一段内容注入两遍,白烧上下文。怎么避:一次只发一种,用测试文件里”出现了不该出现的字段就失败”那种写法兜住。

用 heredoc 拼大字符串。 会踩是因为这是最自然的写法,问题只在特定 bash 版本上出现,本机不复现就更难想到。怎么避:照现在的写法用 printf

收束:改之前先跑一遍这三件事

要判断这套钩子在你机器上是不是真的活着,按顺序做三步就够:手动执行一次 bash hooks/run-hook.cmd session-start,确认有 JSON 输出;用 node 或任意工具解析这段 JSON,确认字段形状和你的宿主对得上、并且没有多余字段;跑 tests/hooks/test-session-start.sh,看它是不是 STATUS: PASSED

接下来读哪个文件取决于你要干什么:想把这套东西接到别的宿主上,读 docs/porting-to-a-new-harness.md,它把接入拆成了几种形状并逐条给了排查方法;只想弄清 Windows 上为什么不工作,读 docs/windows/polyglot-hooks.md 的 Troubleshooting 一节,三种典型症状都列了。但真正决定行为的始终是 hooks/session-start 那几十行代码——这一点文档自己在开头就写了:当文档与代码不一致时,以代码为准。

本文属于 superpowers 方法论专题(共 30 篇,含三篇与其它开源 Agent 项目的对照)。想看把资产铺满的另一种取向,见 ECC 开源 Agent 套件专题

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