gstack 中的 Claude Code Hook 变异实战:AskUserQuestion 的自动决策与确定性记录协议
本文基于 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
AskUserQuestionactually substitute the user's answer viaupdatedInput? 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_id、agent_type。gstack 的实现里对这个 schema 的 TypeScript 类型声明可以对照 question-preference-hook.ts 中的 HookStdin 接口(第 50–63 行):它只声明了实际用到的 session_id、hook_event_name、tool_name、tool_use_id、tool_input 与 cwd 字段,并对 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的、仅带additionalContext的hookSpecificOutput)。
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 的记忆片段),输出不带 permissionDecision 的 additionalContext:
{
"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
updatedInputshape for "pre-resolve this question" isn't structurally pinned in Claude Code docs (spike T4 left as open question).denywith 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 行)中:
- 通过
<gstack-qid:foo-bar>标记提取question_id(无标记则跳过强制执行——哈希 ID 只被观察,永不作为偏好键,对应 D18); - 从 scripts/question-registry.ts 查询
door_type(默认 two-way); - 按"项目级 > 全局"优先级读取偏好(D8);
- 应用规则:
never-ask + one-way→ 透传(单向门永远要问人);never-ask + two-way + 标记→ 自动决策;always-ask或无偏好 → 透传。
几个关键实现细节:
- 弃权即空 stdout:
passThrough()(第 100–120 行)严格遵守 spike 的 defer 陷阱结论,注释里直接引用了 #2035/#2006; - 多问题全有或全无:对一次携带多个问题的 AUQ,只有当所有问题都满足"有标记 + never-ask + 安全门型"时才整体 deny 自动决策,混合情况一律透传让用户作答(第 416–456 行的
fullyAutoDecidable逻辑); - 未注册 ID 的防御:#2024 修复后,对注册表外的 question_id 会额外跑 scripts/one-way-doors.ts 的
classifyQuestion正则分类器,避免一个临时性的破坏性问题(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会话自动选推荐项、不阻塞;headless报BLOCKED并停止;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.ts 的 runBin() 调用 bin/ 下的 bash 脚本(如 gstack-question-log、gstack-session-kind),该模块修复了两个 Windows 专属坑:fileURLToPath 替代 new URL(...).pathname(否则路径被解析成 C:\C:\Users\...),以及显式经 Git Bash 执行无扩展名脚本(Windows 不支持 shebang)。调用时还特意以"发起工具调用时的 cwd"为工作目录运行,保证 gstack-slug 解析到用户实际所在的项目而非 hook 脚本所在目录。
8. spike 移交实现的三个遗留问题
spike 末尾列出了留给实现阶段的开放问题,现在可以逐一对照仓库看它们的落点:
- 推荐项解析范围:D2 规定先解析
(recommended)标签,标签位于选项的label字段上;实现需要遍历tool_input.questions[*].options[*]找 label 后缀。实际工作中选项形如ship/SKILL.md.tmpl产出的"A) Fix now" (recommended)。→ 已实现为extractRecommended()(见 7.1 节),并增加了散文回退与歧义拒绝。 - 自动决策事件打标:需要 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 直接完成。 - 超时行为: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 > defer,updatedInput 合并顺序未定义 |
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+updatedInput 或 deny+reason 的决策分支——gstack 的三个实现文件就是这套协议在真实约束下(超时 5 秒、永不阻塞会话、Windows 兼容、MCP 变体)的完整参考。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00