gstack /unfreeze 技能详解:如何清除 /freeze 目录级编辑边界而不结束会话
/unfreeze 是 gstack 安全技能家族(/careful、/freeze、/guard)中的"解锁"技能,用于清除 /freeze 设置的会话级编辑边界,让 Agent 恢复对全部目录的编辑权限。阅读本文后,你将掌握 /unfreeze 的触发时机、核心命令的每一行含义、底层 hook 的 fail-closed 判定逻辑、状态文件 freeze-dir.txt 的路径解析机制,以及与 /guard、/investigate 等技能的协作关系。
一、/unfreeze 在 gstack 中的定位
gstack 是一个以"CEO、设计师、工程经理、QA"等 23 个有主见工具为形态的 Claude Code 工作流集合。为了在调试时防止 Agent 顺手"修复"无关代码,/freeze 会把所有 Edit / Write 操作**限制(blocked,而非仅警告)**在某个指定目录内;/unfreeze 则是与之对称的"解除操作"。
两者的技能元信息(frontmatter)揭示了各自定位:
| 维度 | /freeze | /unfreeze |
|---|---|---|
| description | Restrict file edits to a specific directory for the session | Clear the freeze boundary set by /freeze, allowing edits to all directories again |
| 核心触发词 | freeze edits to directory、lock editing scope、restrict file changes | unfreeze edits、unlock all directories、remove edit restrictions |
| allowed-tools | Bash、Read、AskUserQuestion | Bash、Read |
| hooks | PreToolUse 匹配 Edit / Write | 无 |
需要特别注意的是:解除边界不需要结束会话。这是 /unfreeze 与"直接关闭对话重来"两种做法的本质区别——它的语义是"扩宽当前会话的编辑范围,同时保留会话内的其它状态"。在 SKILL.md 的路由规则中,User asks to restrict edits to a directory → invoke /freeze or /unfreeze,二者是一对由主技能入口统一调度的互逆命令。
二、技能触发条件与使用时机
根据 unfreeze/SKILL.md 的"When to invoke this skill",满足以下任一情况就应调用 /unfreeze:
- 你想在不结束当前会话的前提下,把编辑范围从"冻结目录"重新扩大为"全部目录";
- 用户提出了 "unfreeze"、"unlock edits"、"remove freeze"、"allow all edits" 等明确请求。
三、核心命令逐行拆解
/unfreeze 的实际执行体很精简,但每一段都有明确职责。它由两个 bash 块组成。
3.1 技能使用埋点
mkdir -p ~/.gstack/analytics
echo '{"skill":"unfreeze","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
该块向 ~/.gstack/analytics/skill-usage.jsonl 追加一条 JSONL 记录,字段含 skill(固定为 unfreeze)、ts(UTC 时间戳)与 repo(通过 git rev-parse --show-toplevel 探测的仓库名,探测失败时回退为 unknown)。末尾的 2>/dev/null || true 保证这是一次"尽力而为"(best-effort)的埋点——即使写日志失败,也绝不会影响后续真正的解锁动作。埋点模式在整个技能家族中是统一的,/freeze 与 /guard 的 SKILL 文档中均以相同的 JSONL 结构开头。
3.2 清除边界(Clear the boundary)
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
STATE_DIR="$GSTACK_STATE_ROOT"
if [ -f "$STATE_DIR/freeze-dir.txt" ]; then
PREV=$(cat "$STATE_DIR/freeze-dir.txt")
rm -f "$STATE_DIR/freeze-dir.txt"
echo "Freeze boundary cleared (was: $PREV). Edits are now allowed everywhere."
else
echo "No freeze boundary was set."
fi
逐行语义如下:
eval "$(...gstack-paths)":导入 gstack 的运行时路径变量。bin/gstack-paths 会输出GSTACK_STATE_ROOT、PLAN_ROOT、TMP_ROOT三个变量。其中状态根目录决定 freeze 状态文件写在哪里。STATE_DIR="$GSTACK_STATE_ROOT":取状态根目录,默认解析为$HOME/.gstack(详见下文第五节)。- 状态检查与删除:若
freeze-dir.txt存在,先把原值读入PREV用于向用户汇报,随后删除该文件,并输出Freeze boundary cleared (was: <原目录>);若文件本就不存在,则输出No freeze boundary was set.,表示当前本就处于"无冻结"状态。
注意:这里的删除动作只能说明"gstack 不再认为存在边界"。真正决定是否放行 Edit/Write 的是注册在会话里的 PreToolUse hook(详见第四节)。
四、清除边界之后:hook 依然注册,只是"放行一切"
/unfreeze 文档末尾明确指出一个容易被忽略的语义:
/freezehooks are still registered for the session — they will just allow everything since no state file exists. To re-freeze, run/freezeagain.
也就是说,/unfreeze 不会反注册 hook。/freeze 的 frontmatter 中注册了两条 PreToolUse 规则,分别匹配 Edit 与 Write 工具,调用 freeze/bin/check-freeze.sh:
hooks:
PreToolUse:
- matcher: "Edit"
hooks:
- type: command
command: "bash $HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"
statusMessage: "Checking freeze boundary..."
- matcher: "Write"
hooks:
- type: command
command: "bash $HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"
当 hook 在每次 Edit / Write 调用时被触发,它首先检查状态文件是否存在(freeze/bin/check-freeze.sh):
# If no freeze file exists, allow everything (not yet configured)
if [ ! -f "$FREEZE_FILE" ]; then
echo '{}'
exit 0
fi
这正是 /unfreeze"删除即解锁"原理的落地处:状态文件是边界的唯一事实来源(source of truth),文件不存在即边界不存在。因此 /unfreeze 删除文件后,hook 仍然每调用必触发,但每次都命中 {} 放行分支。若需要重新设立边界,只需再次运行 /freeze 写入新的 freeze-dir.txt。
五、状态文件与路径解析机制
/unfreeze 能正确工作,前提是它读写状态文件的位置与 /freeze 的 hook 读取位置完全一致。两者最终都收敛到 freeze-dir.txt,但各自路径解析略有分工:
- /freeze 写、/unfreeze 删:通过
eval "$(gstack-paths)"取GSTACK_STATE_ROOT; - check-freeze.sh 读:freeze/bin/check-freeze.sh 使用
STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}"。
bin/gstack-paths 对 GSTACK_STATE_ROOT 的解析回退链是:
GSTACK_HOME -> CLAUDE_PLUGIN_DATA(仅当 CLAUDE_PLUGIN_ROOT 含 gstack)-> $HOME/.gstack -> .gstack
其中对 CLAUDE_PLUGIN_DATA 的信任加了守卫:只有 CLAUDE_PLUGIN_ROOT 确认当前进程是 gstack 插件时才采用,避免其它插件(如 codex)泄漏的 CLAUDE_PLUGIN_DATA 污染 gstack 状态目录(见 bin/gstack-paths)。在标准安装下(插件数据目录或 $HOME/.gstack),两者解析到同一目录,读写天然一致。由于输出值经过 printf %q 转义,eval 可以字节级还原路径(含空格、反斜杠等场景),调用方仍需对 "$GSTACK_STATE_ROOT" 做引号展开。
状态文件 freeze-dir.txt 本身只有一行:冻结目录的绝对路径。为保证判定正确,写入方会强制以 / 结尾(freeze/SKILL.md 的 Setup 步骤执行 FREEZE_DIR="${FREEZE_DIR%/}/"),这样前缀匹配时 /src 不会误匹配 /src-old。读取方则做进一步加固(freeze/bin/check-freeze.sh):
- 只修剪首尾空白:历史实现用
tr -d '[:space:]'会连内部空格一起删掉,导致~/My Project/src这类带空格边界永远无法匹配;现在改用sed只清首尾; - 展开字面
~:状态文件里的~/前缀不会在变量中自动展开,脚本将其显式映射为$HOME。
六、底层原理:fail-closed 的 hook 判定与共享 JSON 提取器
为什么"删掉状态文件"就足以解锁?因为 /freeze 的设计极性是默认关闭(fail-closed),这在 freeze/SKILL.md 的 "How it works" 与 freeze/bin/check-freeze.sh 头注释中都被反复强调:a boundary that fails open is not a boundary。
具体判定流程(freeze/bin/check-freeze.sh):
- 从 stdin 读取 Edit/Write 工具的输入 JSON;
- 用共享提取器取出
tool_input.file_path; - 无法解析的载荷一律 deny(fail-closed)——hook 看不懂的东西绝不能放行;
- 能解析但无
file_path的载荷(非文件类工具)放行; - 解析失败但有文件路径时,规范化路径(去双斜杠与尾部斜杠)、解析
..与符号链接(含末级组件,防止"边界内软链指向边界外"绕过),再做前缀匹配; - 命中边界前缀输出
{}放行;否则输出 deny 决策并记录一次hook_fire分析事件。
一个关键的协议细节:拒绝决策必须嵌套在 hookSpecificOutput 字段下返回(careful/bin/hook-extract.sh):
gstack_hook_decision() {
...
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"%s","permissionDecisionReason":%s}}\n' "$_ghd_decision" "$_ghd_encoded"
}
Claude Code 会忽略顶层 permissionDecision,只有嵌套在 hookSpecificOutput 下的 permissionDecision: "deny" 才能真正阻断操作——文档注释中明确记录了"顶层字段会静默 no-op"这一坑。
另外值得注意的工程实践是:/freeze 与 /careful 共用了同一份 JSON 工具脚本 careful/bin/hook-extract.sh。其头注释说明:历史上两个 hook 各带一份解析器副本,修复了 careful 副本的"转义引号截断" bug 后 freeze 仍悄悄用着坏副本;合并为一份后,任何解析修复天然同时覆盖两个 hook。若该共享文件缺失(安装损坏或升级中断),freeze hook 会内联构造一条 deny 决策,继续 fail-closed 阻断(freeze/bin/check-freeze.sh),而不是静默放行。
七、与 /guard、/investigate 的协作生态
- /guard = /careful + /freeze:/guard 是"全安全模式",同时注册"危险命令警告"(check-careful.sh,作用于 Bash)与"目录编辑边界"(check-freeze.sh,作用于 Edit/Write)。它的 Setup 同样会写
freeze-dir.txt,因此文档明确说明解除编辑边界的方式正是/unfreeze:"To remove the edit boundary, run /unfreeze. To deactivate everything, end the session." - /investigate 自动冻结:investigate/SKILL.md 在调试时检测受影响文件所在的最窄目录,自动把它写入冻结状态文件,并在结束时提示用户 "Run /unfreeze to remove the restriction." 换句话说,/unfreeze 是调试闭环(freeze → 修复 → unfreeze)的收尾动作。
- 会话级生命周期:边界只存在于当前会话(session-scoped)。hook 每次调用读取状态文件、每调用一次判定一次;结束会话同样等效于清除边界。
八、安装前提与运行环境
运行 /unfreeze 需要以 gstack 的标准安装为前提(本仓库是只读的,仅用于查看):
- 技能按
~/.claude/skills/gstack/前缀安装,unfreeze/SKILL.md 与 /freeze、/careful、/guard 一起由 gstack 的 setup 脚本安装(/guard 的依赖说明特别强调三者需同时安装,因为 guard 直接引用 freeze/careful 的 hook 脚本); gstack-paths存在于同一安装目录的bin/下,运行时通过eval引入;- hook 脚本的 frontmatter 路径一律以
$HOME/.claude/skills/gstack/锚定。相关测试 test/hook-scripts.test.ts 断言所有command:行必须使用$HOME锚定、不得使用CLAUDE_SKILL_DIR;test/investigate-freeze-path.test.ts 也对 investigate 中引用check-freeze.sh的 {CLAUDE_SKILL_DIR}` 这类相对引用会静默无法解析,导致守卫失效。
九、使用要点与常见问题
- /unfreeze 不等于"反安装 /freeze":hook 依然注册、每调用仍执行,只是因状态文件不存在而放行一切。要重新冻结请再次运行
/freeze。 - /freeze 不是安全沙箱:/freeze 的 Notes 明确指出它只拦截 Edit/Write 工具,Bash 里的
sed等命令仍可修改边界外文件——它是"防误改",不是访问控制。/unfreeze 解锁的同样是这层"防误改",而非系统级权限。 - 判别边界是否存在的权威方法:查看状态目录下是否存在
freeze-dir.txt(默认$HOME/.gstack/freeze-dir.txt)。/unfreeze 输出No freeze boundary was set.即表示该文件不存在。 - 带空格的目录边界:supported。写入侧保留内部空格、读取侧只清首尾空白、hook 采用真正的 JSON 解析而非 grep 截断,三重保障确保路径匹配可靠。
若想快速上手体验完整链路,可以按 /freeze 的 Setup 流程冻结一个目录、触发一次越界编辑观察 hook 拦截,再运行 /unfreeze 观察边界清除提示——整个过程无需结束会话。
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