首页
/ gstack 中的 Claude Code Hook 变异实战:AskUserQuestion 的自动决策与确定性记录协议

gstack 中的 Claude Code Hook 变异实战:AskUserQuestion 的自动决策与确定性记录协议

2026-09-06 23:41:13作者:蔡怀权

本文基于 gstack 仓库中的 spike 文档 claude-code-hook-mutation.md 展开,回答一个具体的工程问题:Claude Code 的 PreToolUse hook 能否通过 updatedInput 直接"替换"用户在 AskUserQuestion 工具中的回答?文章将完整给出 hook 的 stdin/stdout 协议、permissionDecision 四种取值的精确语义(包括 defer 的已知陷阱)、matcher 的覆盖策略与多 hook 并发规则,并结合同仓库中三个真实 hook 实现(自动决策、答案记录、失败兜底)说明这套协议在 gstack 的 plan-tune 体系里是如何落地的。读完后,你可以照着 spike 中的 settings.json 片段注册自己的 hook,并理解 gstack 为何最终选择了"保守替代方案"。

1. Spike 要回答的问题

spike 文档开宗明义地提出了它要解决的问题(原文档标注服务于 D10 与 D19/Codex 两个决策,下游消费者为 T3、T5、T6、T8 四个任务):

Can a PreToolUse hook on AskUserQuestion actually substitute the user's answer via updatedInput? If yes, what's the exact protocol?

即:针对 AskUserQuestion(gstack 中简称 AUQ)这个"向用户提问"的工具,一个 PreToolUse hook 能否真的用 updatedInput 把用户的回答替换掉?如果能,精确的协议是什么?

spike 的结论是肯定的:updatedInput 是受支持机制,依据是 Claude Code 官方 hooks 文档(spike 标注为 2026-04 的确认版本)。gstack 需要这个机制的动机来自 plan-tune 的 never-ask 偏好:当用户声明"某类问题不要再问我"时,hook 应当代替用户自动选择推荐选项,而不是依赖模型自觉遵守提示词。

2. hook 的输入协议:stdin schema

Claude Code 调用 hook 时,会把一个 JSON 对象写入 hook 进程的 stdin。spike 中记录的 PreToolUse / PostToolUse 通用 schema 如下:

{
  "session_id": "abc123",
  "transcript_path": "/path/to/transcript.jsonl",
  "cwd": "/current/working/dir",
  "permission_mode": "default",
  "effort": { "level": "medium" },
  "hook_event_name": "PreToolUse",
  "tool_name": "AskUserQuestion",
  "tool_input": { /* tool-specific */ },
  "tool_use_id": "unique-id-12345"
}

在 subagent 上下文中还可能出现可选字段 agent_idagent_type。gstack 的实现里对这个 schema 的 TypeScript 类型声明可以对照 question-preference-hook.ts 中的 HookStdin 接口(第 50–63 行):它只声明了实际用到的 session_idhook_event_nametool_nametool_use_idtool_inputcwd 字段,并对 tool_input.questions[*]question/options/multiSelect 做了结构约束。

3. hook 的输出协议:PreToolUse 的 stdout schema

hook 通过在 stdout 输出一段 JSON 来影响工具调用。spike 记录的 allow + updatedInput 形态为:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "permissionDecisionReason": "auto-decided by plan-tune preference",
    "updatedInput": { /* shallow-merged into original tool_input */ },
    "additionalContext": "optional context for Claude"
  }
}

3.1 permissionDecision 的四种取值

spike 对四个取值给出了精确语义,其中 defer 是重点陷阱:

  • "allow" — 放行,可附带 updatedInput
  • "deny" — 阻断,反馈回给 Claude(注意:按 D 系列决策中的 Codex 修正,deny 不是"合成一个假答案",而是把原因作为反馈交给模型);
  • "ask" — 升级给用户决定;
  • "defer" — 暂停该工具调用,等待外部恢复(Claude Code v2.1.89+ 的 headless 特性:之后用 -p --resume 重新求值)。spike 特别警告:永远不要defer 来表达"无意见"——在交互式会话中没有任何东西会恢复被暂停的调用,工具会以 Tool result missing due to internal error 死掉(对应 issue #2035、#2006)。要弃权(abstain),应当退出码 0 且 stdout 为空(或者只输出不含 permissionDecision 的、仅带 additionalContexthookSpecificOutput)。

3.2 updatedInput 的语义

updatedInput 的合并规则是浅合并(shallow merge):只把返回对象中出现的字段覆盖到原始 tool_input 上,且仅在 permissionDecision: "allow" 时有效。正是这一点让"为 never-ask 偏好自动代入答案"成为可能。

3.3 三个典型输出示例(spike 原文继承)

自动决策(PreToolUse,never-ask 偏好 + 非单向门)

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "permissionDecisionReason": "plan-tune: never-ask preference on ship-test-failure-triage",
    "updatedInput": {
      "questions": [{ /* same as input, but with auto-selected answer */ }]
    }
  }
}

透传(无偏好,或单向门安全覆盖):退出码 0、stdout 为空。如果有需要注入的上下文(比如 plan-tune 的记忆片段),输出不带 permissionDecisionadditionalContext

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "additionalContext": "[plan-tune memory] Past answers suggest: ..."
  }
}

spike 附带的历史注记值得所有 hook 作者警惕:这个示例最初输出的是 permissionDecision: "defer",在 Claude Code v2.1.89 给 defer 赋予"暂停-恢复"语义后,导致每一次 AskUserQuestion 都失败(#2035),随后才改为空 stdout。

PostToolUse 捕获(总是执行)

{
  "hookSpecificOutput": {
    "hookEventName": "PostToolUse"
  }
}

(PostToolUse hook 也可以设置 additionalContext 向工具结果追加内容;v1 的捕获不需要。)

4. Matcher:同时覆盖原生与 MCP 变体

~/.claude/settings.json 中 hook 的 matcher 字段在包含正则元字符时按 JS 正则解析;只含字母/下划线的 matcher 则是精确匹配。要同时覆盖原生 AskUserQuestion 与任意 MCP 服务器暴露的同名工具,spike 给出的写法是:

"matcher": "(AskUserQuestion|mcp__.*__AskUserQuestion)"

这里有一个 gstack 特有的背景:Conductor 通过 --disallowedTools 禁用原生 AskUserQuestion,改走 mcp__conductor__AskUserQuestion 路由——如果 matcher 不带 MCP 后缀,hook 在那条路径上根本不会触发。gstack 三个 hook 的运行时还各自做了一层防御性过滤(不依赖 matcher 已经过滤),例如 question-log-hook.ts 第 280–286 行:

if (
  toolName !== 'AskUserQuestion' &&
  !toolName.match(/^mcp__.+__AskUserQuestion$/)
) {
  // Matcher should have filtered this out; defensive no-op.
  process.exit(0);
}

5. 多 hook 并发与决策优先级

spike 引用的平台行为是:所有匹配的 hook 并行执行,完全相同的 handler 会被自动去重。对 gstack 的含义:

  • gstack 恰好注册一个 PreToolUse hook 和一个 PostToolUse hook(AUQ 形状的工具名);
  • 如果用户自己另有一个对 AskUserQuestion 也返回 updatedInput 的 hook,两次 updatedInput 的合并顺序是未定义的
  • 缓解措施:在 bin/gstack-settings-hook 安装器的提示中记录该约束(见 bin/gstack-settings-hook),用户在接受前可以通过 diff 预览发现冲突。

多个 hook 都给出决策时的 permissionDecision 优先级:deny > ask > allow > defer,最严格的胜出。

6. settings.json 注册片段(T8 hook 安装器用)

spike 给出的完整注册片段如下,可直接用于理解安装器 bin/gstack-settings-hook 写入 ~/.claude/settings.json 的形状:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "(AskUserQuestion|mcp__.*__AskUserQuestion)",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/skills/gstack/hosts/claude/hooks/question-preference-hook",
            "timeout": 5
          }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "(AskUserQuestion|mcp__.*__AskUserQuestion)",
        "hooks": [
          {
            "type": "command",
            "command": "$CLAUDE_PROJECT_DIR/.claude/skills/gstack/hosts/claude/hooks/question-log-hook",
            "timeout": 5
          }
        ]
      }
    ]
  }
}

两个执行细节(spike 原文 + 仓库佐证):

  • hook 的 command 字符串由 Claude Code 的 hook runner 经 /bin/sh 执行,必须使用绝对路径或 $CLAUDE_PROJECT_DIR 变量替换
  • 仓库里的 hook 本体是 TypeScript 文件,settings.json 引用的其实是一个 bash shim(见 question-preference-hook),内容仅 5 行:
#!/usr/bin/env bash
# Bash shim — Claude Code hooks run `command` strings via /bin/sh, so this
# wrapper makes the TypeScript hook executable via bun. Settings.json
# references this file directly.
set -e
HERE="$(cd "$(dirname "$0")" && pwd)"
exec bun "$HERE/question-preference-hook.ts"

这与 spike 中"Hook commands take bun invocation under the hood"的描述一一对应。

7. 协议落地:gstack 的三个 hook 实现

spike 定下协议契约后,gstack 在 hosts/claude/hooks/ 下实现了三个 hook,各自对应 plan-tune 的不同任务。

7.1 question-preference-hook:为什么用 deny+reason 而不是 allow+updatedInput

question-preference-hook.ts 是 T6 的自动决策 hook。有意思的是,它的文件头注释明确说明了一个"协议验证了但实现选了更保守路径"的决策:

Why deny+reason instead of allow+updatedInput: AskUserQuestion's updatedInput shape for "pre-resolve this question" isn't structurally pinned in Claude Code docs (spike T4 left as open question). deny with a reason that names the auto-decided option is conservative + reliable ... When the spike around input mutation lands, we can swap to allow+updatedInput without changing the contract.

也就是说,spike 证明了机制可行,但"预解析问题"这一具体形态的 updatedInput 形状未在文档中被结构化固定,实现因此选择 deny + 在 permissionDecisionReason 里写明自动决策结果,让模型读到被拒绝的反馈后直接按推荐选项继续、不再次提问。这个决策树的完整形态在文件头注释(第 8–19 行)中:

  1. 通过 <gstack-qid:foo-bar> 标记提取 question_id(无标记则跳过强制执行——哈希 ID 只被观察,永不作为偏好键,对应 D18);
  2. scripts/question-registry.ts 查询 door_type(默认 two-way);
  3. 按"项目级 > 全局"优先级读取偏好(D8);
  4. 应用规则:never-ask + one-way → 透传(单向门永远要问人);never-ask + two-way + 标记 → 自动决策;always-ask 或无偏好 → 透传。

几个关键实现细节:

  • 弃权即空 stdoutpassThrough()(第 100–120 行)严格遵守 spike 的 defer 陷阱结论,注释里直接引用了 #2035/#2006;
  • 多问题全有或全无:对一次携带多个问题的 AUQ,只有当所有问题都满足"有标记 + never-ask + 安全门型"时才整体 deny 自动决策,混合情况一律透传让用户作答(第 416–456 行的 fullyAutoDecidable 逻辑);
  • 未注册 ID 的防御:#2024 修复后,对注册表外的 question_id 会额外跑 scripts/one-way-doors.tsclassifyQuestion 正则分类器,避免一个临时性的破坏性问题(DESTRUCTIVE)带着旧的 never-ask 偏好被自动放行;
  • 推荐项解析(spike 遗留问题 1 的落地)extractRecommended()(第 278–297 行)先找选项 label 上的 (recommended) 后缀(正则 /\(recommended\)\s*$/i),失败则回退到问题文本里的 Recommendation: X 散文匹配,多候选或零候选都视为歧义而拒绝自动决策(D2 的 refuse-on-ambiguous);
  • deny 的记账补偿deny 会使 PostToolUse 不再触发,所以 PreToolUse 一侧自己调用 gstack-question-log 记录 source: 'auto-decided' 事件(logAutoDecided,第 325–357 行),并写 .auto-decided-<tool_use_id> 标记文件——这正是 spike"遗留问题 2:自动决策事件打标"中提出的 marker file 方案的实现;
  • Conductor 特殊路径:在 Conductor 会话中 AUQ 不可靠(原生被禁用、MCP 变体不稳定),hook 会 deny 并把提问强制改写成 prose 决策简报(第 480–498 行)。

7.2 question-log-hook:PostToolUse 确定性捕获

question-log-hook.ts 是 T5 的答案记录 hook:从 tool_input/tool_response 中抽取每个问题与用户选项,经 gstack-question-log 落库,"no agent compliance required"。值得注意的鲁棒性设计:

  • tool_response 形状不固定:原生与 MCP 变体返回结构不同,extractUserChoices()(第 166–241 行)依次尝试 Shape D({ answers: {<问题文本>: <答案>}, annotations? },当前原生形态)、Shape A(answers 数组)、Shape B(questions[*].user_answer),未识别的形状只记诊断日志而不嵌入记录("that poisons user_choice for every downstream metric");
  • question_id 策略:标记优先(<gstack-qid:foo-bar>),无标记时回退到 sha1(skill::question::sorted-options)hook- 前缀哈希 ID——同样只被观察、不作为偏好键;
  • 不变式:永远退出码 0,失败只写 ~/.gstack/hook-errors.log,绝不阻塞用户会话。

7.3 auq-error-fallback-hook:spike 中 UNVERIFIED 一节的落地(OV3:B)

spike 文档保留了一个当时无法在 harness 中验证的开放问题:当 MCP 工具调用返回传输/缺结果错误(Conductor bug 的 [Tool result missing due to internal error])时,Claude Code 是否会触发 PostToolUse hook?官方文档只覆盖成功路径。spike 的决策(OV3:B = A)是:防御性地照样构建,因为

  • 它在成功路径上惰性(只有 isErrorResponse(tool_response) 为真才动作);
  • 即使平台在该错误路径上从不触发它,也没有危害;
  • 提示词层的兜底(generate-ask-user-format.ts)无论如何都覆盖该场景——hook 只是可靠性,不是机制本身。

对应的实现是 auq-error-fallback-hook.ts,spike 提到的"确定性单测"即 test/auq-error-fallback-hook.test.ts。两个被单测覆盖的纯函数:

  • isErrorResponse()(第 96–123 行):保守判定错误形状——空值/空串、匹配 tool result missing 哨兵短语(只匹配这个特定短语,避免真实答案如 "Investigate the internal error" 误触发,这是 Codex 评审提出的)、is_error === true(布尔真,而非序列化字符串里的子串)、非空 error 字段,以及 { content: ... } 包装形态;
  • directiveFor(kind)(第 143–169 行):按 SESSION_KIND 注入不同的 additionalContext 指令——spawned 会话自动选推荐项、不阻塞;headlessBLOCKED 并停止;interactive 要求立即渲染 prose 决策(ELI10 + Recommendation 行 + 每个选项一段,附 (recommended) 标记与 Completeness: X/10)。

spike 给出的"后续补全验证"的建议也原样保留在文档中:注册一个只打日志的临时 PostToolUse hook,先(a)触发普通工具错误确认 PostToolUse 在工具出错时到底会不会触发,再(b)复现 Conductor 的 MCP AUQ 失败观察日志;若 (b) 确认可触发,就把该 hook 从"防御/惰性"升级为"已验证"。在此之前,运行时层按 best-effort 对待,提示词层兜底才是保证路径。

7.4 子进程与 Windows 兼容

三个 hook 都通过 spawn-bin.tsrunBin() 调用 bin/ 下的 bash 脚本(如 gstack-question-loggstack-session-kind),该模块修复了两个 Windows 专属坑:fileURLToPath 替代 new URL(...).pathname(否则路径被解析成 C:\C:\Users\...),以及显式经 Git Bash 执行无扩展名脚本(Windows 不支持 shebang)。调用时还特意以"发起工具调用时的 cwd"为工作目录运行,保证 gstack-slug 解析到用户实际所在的项目而非 hook 脚本所在目录。

8. spike 移交实现的三个遗留问题

spike 末尾列出了留给实现阶段的开放问题,现在可以逐一对照仓库看它们的落点:

  1. 推荐项解析范围:D2 规定先解析 (recommended) 标签,标签位于选项的 label 字段上;实现需要遍历 tool_input.questions[*].options[*] 找 label 后缀。实际工作中选项形如 ship/SKILL.md.tmpl 产出的 "A) Fix now" (recommended)。→ 已实现为 extractRecommended()(见 7.1 节),并增加了散文回退与歧义拒绝。
  2. 自动决策事件打标:需要 PostToolUse payload 上的额外字段(如 was_auto_decided: true),spike 提出的方案是 PreToolUse 写标记文件 ~/.gstack/sessions/<id>/.auto-decided-<tool_use_id>、PostToolUse 读取后删除。→ 实现见 markAutoDecided()question-preference-hook.ts 第 308–318 行),且因为 deny 使 PostToolUse 不触发,记账改由 PreToolUse 直接完成。
  3. 超时行为:hook 默认超时 60 秒,但文档对超时后的行为描述很薄。spike 决定显式设置 timeout: 5,让用户在 hook 故障时永远不超过 5 秒等待,超时则回退为透传。→ settings.json 片段中的 "timeout": 5 即此决策。

9. 可验证要点小结

结论 依据
updatedInput 是 PreToolUse 替换 tool_input 的受支持机制,浅合并、仅 allow 时有效 spike 文档 §Answer、§stdout schema
defer 自 CC v2.1.89 起是"暂停待外部恢复",交互式会话中误用会让 AUQ 全部失败 spike §permissionDecision(#2035、#2006)及 question-preference-hook.ts 注释
matcher 需写 `(AskUserQuestion mcp__.*__AskUserQuestion)` 才能覆盖 Conductor 的 MCP 路由
多 hook 决策优先级 deny > ask > allow > deferupdatedInput 合并顺序未定义 spike §Multiple-hook concurrency caveat
hook command 必须绝对路径或 $CLAUDE_PROJECT_DIR;TS 本体经 bash shim 由 bun 执行 spike §Settings.json snippet + 各 bash shim 文件
PostToolUse 在工具出错时是否触发仍属未验证,防御性 hook 按 best-effort 对待 spike §PostToolUse on tool error(UNVERIFIED)+ auq-error-fallback-hook.ts 头注释

如果你要为自己的工具链注册类似的 AUQ hook,建议直接以 spike 文档中的 settings.json 片段为起点,先实现"弃权即空 stdout"的最小安全版本,再按需叠加 allow+updatedInputdeny+reason 的决策分支——gstack 的三个实现文件就是这套协议在真实约束下(超时 5 秒、永不阻塞会话、Windows 兼容、MCP 变体)的完整参考。

登录后查看全文
热门项目推荐
相关项目推荐