get-shit-done Graphify 自动更新 Hook 无法发布的根因与修复:从 `hooks/` 源码到 `~/.claude/hooks/` 的完整发布链路剖析(3579)
本篇技术指南围绕 changeset 档案 .changeset/fix-3579-graphify-hook-publish.md 展开,剖析 get-shit-done(GSD)中 Graphify 知识图谱自动更新 Hook
gsd-graphify-update.sh因发布链路断点而无法到达用户安装目录的问题,并结合 scripts/build-hooks.js 与 bin/install.js 的源码实现,讲解"源码 hook →hooks/dist/构建产物 → 运行时配置目录"三段式发布机制、两处根因修复(白名单补齐 + 子目录镜像)以及防回归的 coverage drift guard。读完你将理解 GSD 的 hook 打包与安装模型,掌握此类"文件从未进入发布物"类缺陷的系统性排查与封堵方法。
一、GSD 的 hook 发布模型:为什么需要一条三段式链路
GSD 内置了大量以 gsd-*.js / gsd-*.sh 命名的 Claude Code 等运行时 hook(PostToolUse、PreToolUse、SessionStart、Stop 等)。它们不可能直接从仓库源码目录被"引用式"使用,而是必须被打包、拷贝到用户运行时的配置目录(例如 ~/.claude/hooks/)。这一过程在源码上被拆成两段、共三个停留点:
- 源码层:hooks/ 目录下平铺所有 hook 顶层文件,外加
lib/子目录存放被 hook 调用的"分离辅助脚本"; - 构建层:scripts/build-hooks.js 把顶层 hook 与白名单子目录里的内容"物化"到 hooks/dist/ 下的构建产物,供 npm 包随发布携带(参见 tests/package-manifest.test.cjs 中对
"hooks"必须列入package.jsonfiles的约束); - 安装层:bin/install.js 读取
hooks/dist/的扁平目录,将产物镜像拷贝到目标运行时的hooks/目录,并完成版本头模板替换、可执行位设置、settings.json钩子注册。
任何一层丢失文件,最终表现都一样:安装目标上不存在该 hook,而用户侧往往只有在功能不生效时才察觉到异常——正如本次 #3579 暴露的问题。
二、changeset 档案解读:Bug #3579 的问题定性
.changeset/fix-3579-graphify-hook-publish.md 是仓库使用的 changeset(变更集)记录,其 frontmatter 声明了 type: Fixed 与 pr: 3579,正文对缺陷给出了精确定义:
gsd-graphify-update.sh一直缺失于scripts/build-hooks.js的HOOKS_TO_COPY白名单,因此它从未进入hooks/dist/;而安装器(bin/install.js)基于hooks/dist/的扁平readdirSync循环拷贝,自然也不可能把它复制到~/.claude/hooks/。
同时,hook 运行时需要的"分离重建辅助脚本" hooks/lib/gsd-graphify-rebuild.sh 同样被遗漏——因为 build-hooks.js 与 bin/install.js 两者此前只遍历顶层文件,从不深入子目录。
换句话说,这是一个单一表层症状、两层结构缺陷的经典发布事故:
- 缺口 1(白名单缺失):构建脚本使用显式 allowlist,新增 hook 若忘记登记,构建层便静默跳过;
- 缺口 2(目录深度受限):即使顶层 hook 打包成功,其依赖的
lib/子目录辅助脚本也不会被任何一层搬运。
三、被遗忘的主角:Graphify 自动更新 Hook 到底是什么
gsd-graphify-update.sh(见 hooks/gsd-graphify-update.sh)是一个 PostToolUse 的 Bash matcher hook:在主分支(HEAD)因 git 操作推进之后,自动在后台重建项目知识图谱,使后续 /gsd-graphify 相关查询(状态、查询、diff)始终基于最新提交的数据。它在功能上是"开箱即停"的选装件:
- 默认关闭,即 hook 文件本身是 no-op;
- 必须满足
.planning/config.json中 同时 存在graphify.enabled: true与graphify.auto_update: true才真正生效(docs/CONFIGURATION.md 中graphify.auto_update默认值为false,以保证升级后既有用户行为不变)。
从脚本头部的注释可见其设计为"8 道快速失败门",按开销从低到高依次拦截非目标场景(参见 hooks/gsd-graphify-update.sh):
- stdin 载荷存在且
tool_name == "Bash"; - 命令匹配 HEAD 推进类 git 操作:
git commit/git merge/git pull/git rebase --continue/git cherry-pick,或等价的gsd-sdk query commit形态; $CI为空(CI 环境抑制);- 位于 git 仓库内;
- 当前分支 == 默认分支(
git.base_branch覆盖,否则在main/master/trunk中探测); - 配置文件双重开关均开启;
graphify二进制在PATH上;- 无重建在途(PID 锁,
kill -0探活,容忍陈旧锁)。
全部通过后,hook 会先同步向 .planning/graphs/.last-build-status.json 写入 status: "running" 的运行信号,随后通过兄弟目录定位 HOOK_DIR/lib/gsd-graphify-rebuild.sh,并以 disown 方式分离后台执行重建。关键点在于它依赖路径 $HOOK_DIR/lib/gsd-graphify-rebuild.sh——该辅助脚本与 hook 在目录结构上必须是"镜像相邻"关系,这正是修复中反复强调"子目录必须同步搬运"的根本原因。
四、根因拆解:链路两个断点的源码证据
断点一:构建白名单没有登记新 hook
在修复前,scripts/build-hooks.js 的 HOOKS_TO_COPY 数组是纯 JS hook 与既有 shell hook(gsd-session-state.sh、gsd-validate-commit.sh、gsd-phase-boundary.sh)的显式清单,并没有包含 gsd-graphify-update.sh。构建脚本随后用 fs.existsSync(src) 检查源文件是否存在,并对 .js 文件执行语法校验(new vm.Script(...) 防止历史上"重复 const 声明被发布"之类的事故),然后才把文件以"先写 staging 再 rename"的原子方式落入 hooks/dist/(见 scripts/build-hooks.js 顶部的注释与 renameAtomicWithRetry 实现)。白名单外的文件即使存在于 hooks/ 顶层,也不会进入 dist——该机制为防泄漏而设计,副作用是"新增即漏"。
断点二:构建与安装只遍历顶层文件
即使补上白名单,构建产物里也只会出现单个 hook 文件。gsd-graphify-update.sh 在 HOOK_DIR/lib/gsd-graphify-rebuild.sh 处查找分离重建辅助脚本,而该文件位于 hooks/lib/ 子目录(同目录还存放着供 .sh hook 调用的 git-cmd.js)。修复前的 build-hooks.js 主循环仅 path.join(HOOKS_DIR, hook) 处理顶层文件;bin/install.js 的安装循环同样只用 fs.readdirSync(hooksSrc) 得到的扁平条目 + isFile() 判断——于是 lib/ 中的内容在发布链路的每一站都被静默丢弃。
需要特别注意的是:安装器对顶层 .sh 文件并非简单复制,而是要逐字节做 {{GSD_VERSION}} 占位符替换(写入 gsd-hook-version 版本头,供 gsd-check-update 检测过期),并补 chmod 0o755 可执行位。子目录内的 .sh 若不被处理,就同时失去"版本戳 + 可执行位"两层保证,详见 bin/install.js 的 hook 拷贝循环。
五、修复落地:白名单、子目录镜像与注册校验
changeset 记载的修复共三条主线,均能在源码中逐一验证:
1. 把 hook 加入构建白名单
scripts/build-hooks.js 现在在 HOOKS_TO_COPY 尾部追加了带注释的条目:
// Graphify auto-update hook (#3347 / PR #3557 / #3579). Opt-in via
// .planning/config.json graphify.auto_update; off by default.
'gsd-graphify-update.sh'
2. 构建层新增白名单子目录复制
scripts/build-hooks.js 新增常量并接入主流程:
// Subdirectories under hooks/ whose contents must also ship to dist. Each
// entry is copied as `hooks/<dir>/*` → `hooks/dist/<dir>/*` so detached
// helpers (e.g. hooks/lib/gsd-graphify-rebuild.sh) resolve from the hook's
// installed runtime path. See #3579.
const HOOKS_SUBDIRS_TO_COPY = ['lib'];
构建主循环(scripts/build-hooks.js)随后会针对 lib 递归 readdirSync(srcDir, { withFileTypes: true }),仅复制文件条目,复用与顶层一致的"语法校验 → staging → rename 原子替换"逻辑,并将 .sh 的可执行位在首次可观察前就置好(chmodSync(stagedDest, 0o755))。产物因此变为 hooks/dist/gsd-graphify-update.sh + hooks/dist/lib/gsd-graphify-rebuild.sh。
3. 安装层镜像子目录到目标
bin/install.js 对 readdirSync(hooksSrc) 得到的目录条目分支处理:一层递归进入 lib/,将每个文件镜像写入目标 hooks/lib/;对 .sh 同样执行版本占位符替换与可执行位设置。这样 hook 在安装环境里通过 dirname "$0" 上溯后拼接 lib/gsd-graphify-rebuild.sh 的 REBUILD_SCRIPT 定位就始终成立。
此外,bin/install.js 的安装后校验清单 expectedShHooks 同步纳入了 'gsd-graphify-update.sh'(缺失时给出非致命警告),而 hook 的 settings.json 注册分支(bin/install.js)也只有在目标文件真实存在时才往 PostToolUse 事件推送 matcher: 'Bash' 的配置条目,否则会打印"Skipped graphify auto-update hook — gsd-graphify-update.sh not found at target"。三条防线相互印证,避免"文件缺失但注册成功"或"文件在但未注册"的隐性半残状态。
六、防回归:coverage drift guard 与回归测试网
changeset 强调最后补上了一道 coverage drift guard:此后 hooks/ 顶层每个 *.sh 都必须出现在 HOOKS_TO_COPY 中,杜绝再次出现"新增 hook 忘登记、静默不发版"。这道防线体现在回归测试上:
- tests/graphify-visualization.test.cjs 新增了
#3579的 Gap 1 与安装两段测试:先真实执行build-hooks.js,断言每个顶层hooks/*.sh都被物化到hooks/dist/(若未来有人忘加白名单,此用例会直接列出 missing 文件而失败);再断言hooks/dist/gsd-graphify-update.sh与hooks/dist/lib/gsd-graphify-rebuild.sh均存在;最后以CLAUDE_CONFIG_DIR指向临时目录的方式跑完整安装流程,验证两个文件都落地到目标 hooks 目录,且安装输出不再出现 "Missing expected hook" 或 "Skipped graphify auto-update hook" 警告。 - tests/orphaned-hooks.test.cjs 则从另一方向守卫生态:
gsd-check-update-worker.js的受管 hook 清单必须与build-hooks.js中HOOKS_TO_COPY的 JS 条目一致,避免运行时更新器与发布清单产生认知分裂。 - 历史回归基线 tests/bug-1834-sh-hooks-installed.test.cjs 早已验证三类社区
.shhook 的部署、可执行位与expectedShHooks告警覆盖——#3579 相当于把同类保障平移到 graphify hook 与lib/辅助脚本上。
七、从 Bug 中沉淀的工程原则
回顾 #3579,可以归纳出三条可复用的发布工程经验:
- 显式白名单需要配套"全量覆盖断言":只要发布采用 allowlist,就必然引入"漏登记"这一风险类别,必须用测试把"源目录实际文件 ⊆ 白名单"固化为不可绕过的约束;
- 路径寻址的辅助文件必须与主文件同步镜像:凡 hook 通过相对路径(
dirname "$0"上溯)加载兄弟辅助脚本,构建与安装两层都必须保留目录结构,否则会出现"顶层文件在、运行期依赖缺失"的隐蔽故障; - 版本戳与可执行位是
.shhook 的隐形契约:安装层对.sh的{{GSD_VERSION}}替换与0o755处理不可省略,否则更新器无法识别过期 hook、运行时直接以不可执行文件启动而报错。
对使用者而言,本修复是透明的:升级到包含 #3579 的版本后重新执行 npx get-shit-done-cc --claude --global(或对应运行时参数),即可在 ~/.claude/hooks/ 看到 gsd-graphify-update.sh 与 hooks/lib/gsd-graphify-rebuild.sh;只有当你需要让提交自动驱动知识图谱重建时,才需在 .planning/config.json 中显式开启 graphify.enabled: true 与 graphify.auto_update: true,其余场景 hook 始终保持零成本空转。
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 StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python08
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00