Hooks

横切行为环绕循环,而不是纠缠在循环内部。

PreToolUse / PostToolUse / Stop —— 生命周期回调。

90 行钩子协议

问题

s03 里我们把审批写进了循环体。现在想加日志、加指标、加"工具运行前自动更新 todo"、加"每次会话开始读一遍 AGENTS.md"……每加一个横切需求,都要改循环内部代码。

横切关注点(审计、监控、策略、仪式)应该环绕循环,而不是纠缠在循环内部。你要的是一种机制:在循环的固定生命点(工具调用前、工具调用后、会话结束)挂回调,回调来自外部配置,循环本身不知道它们的存在。

解决方案

定义钩子协议:循环在生命点检查一张 hooks 表,命中就执行外部命令并读取其 JSON 输出作为决策。

// hooks.json —— 纯配置,不碰代码
{
  "PreToolUse": [{ "matcher": "bash", "command": "node hooks/audit.mjs" }],
  "PostToolUse": [{ "matcher": "*", "command": "node hooks/touch.mjs" }],
  "Stop": [{ "command": "node hooks/summarize.mjs" }]
}

工作原理

第 1 步:钩子协议定义——每个钩子收到 JSON 上下文(工具名、参数、会话信息),输出 JSON 决策({ "decision": "allow" | "deny", "reason": "..." }{ "continue": true })。

async function runHook(hook, context) {
  const { execFile } = await import("node:child_process");
  const [cmd, ...args] = hook.command.split(/s+/);
  const out = await execFile(cmd, args, { input: JSON.stringify(context), encoding: "utf8" })
    .catch((e) => ({ stdout: `{"continue":false,"reason":${JSON.stringify(e.message)}}` }));
  return JSON.parse(out.stdout ?? "{}");
}

第 2 步:循环在生命点调用钩子。工具执行前,钩子可以拒绝工具(deny)——这是 s03 策略闸门的另一种实现方式:

async function gateWithHooks(name, args) {
  for (const hook of HOOKS.PreToolUse ?? []) {
    if (hook.matcher && hook.matcher !== "*" && !name.startsWith(hook.matcher)) continue;
    const result = await runHook(hook, { tool_name: name, tool_input: args });
    if (result.decision === "deny") return `DENIED by hook: ${result.reason}`;
  }
  return "allow";
}

第 3 步:把钩子检查挂进循环的三个固定点:PreToolUse(执行前)、PostToolUse(执行后)、Stop(循环结束前)。循环代码只多了三行调用:

// 在 s03 的 gate 之前:
const verdict = await gateWithHooks(name, args);
if (verdict !== "allow") { /* 拒绝路径 */ }
// 在 execute 之后:
await runHooks("PostToolUse", { tool_name: name, tool_output: output });
// 在 return 之前:
await runHooks("Stop", {});

新增一个横切能力 = 新增一个钩子命令,循环零改动。钩子协议把"策略"从"机制"里剥离了出来。

试一下

运行 s04,先写一个 hooks/audit.mjs(记录每个工具调用到日志),再让它跑几个任务:

  • "读取 README.md 前 5 行"
  • "运行 npm test"(观察审计日志里出现了什么)

观察重点:PreToolUse 拒绝时模型看到什么?如果钩子命令本身崩溃(exit 非零),循环应该怎样——中止还是继续?(DSH 的答案:钩子崩溃本身不杀死循环,但会进入错误恢复——s11 的主题。)

以下内容基于 packages/hooks 的核查。DSH 的钩子哲学与你的最小实现完全一致,但方向相反:

一、DSH 有两层"钩子"

  • 原生扩展点:DSH 的规范扩展面是类型化的事件拦截点——agent/pre-stepagent/requesttools/pre-execute 等 waterfall 事件。一个"原生钩子"就是一个挂在扩展点上的普通 Cordis 插件。没有独立的钩子语言,没有第二个运行时。
  • 外部钩子桥packages/hooks/hook-protocol 定义共享的 shell 钩子线协议;hooks-claude-codehooks-codex 把 Claude Code / Codex 的 hooks.json 方言翻译到 DSH 的原生扩展点。你可以把已有的 hooks.json 直接指给 DSH 用——这就是"桥"的含义。

二、为什么 DSH 优先原生扩展点

你的最小实现 DSH 的真实实现
钩子 = 外部命令 + JSON 协议 首选:插件直接订阅类型化事件(同进程、有类型、可组合)
钩子错误需要自己定义 事件监听者参与 waterfall,必须 next() 委托,错误语义由运行时保证
只有 3 个生命点 agent/*tools/*session/* 三个域的事件词汇表

三、waterfall 语义

DSH 的 tools/pre-executeagent/pre-step 等是 waterfall 事件:监听者按顺序执行,每个必须调用 next() 委托给下一个,返回而不调用 next() 会短路整条链。这正是"多策略可叠加"的结构保证——你的 for...of HOOKS 循环,在 DSH 里是运行时保证的瀑布链。

一句话:你的钩子协议教会了你"横切行为该住在循环外面"。DSH 把它做成了一等公民:事件即扩展点,桥即兼容层。