246 lines
10 KiB
Markdown
246 lines
10 KiB
Markdown
---
|
||
title: "Claude Code /goal 命令深度解析:一个极其轻量的「执念」封装"
|
||
date: 2026-05-16T14:00:00+01:00
|
||
draft: false
|
||
tags: ["Claude Code", "AI", "Agent", "逆向"]
|
||
summary: "/goal 看起来像 Claude Code 的一个新功能,其实只是把 Stop hook 当成 primitive 复用的一层薄封装——用自然语言条件,把模型钉在循环里,直到它认为自己做完了。"
|
||
---
|
||
|
||
最近翻 Claude Code 二进制(v2.1.141)的时候,注意到一个不太显眼但很有意思的内置 slash command:`/goal`。
|
||
|
||
它的定位一句话能讲清楚:**给 Claude 设一个"完成条件",达成前不许停**。
|
||
|
||
听起来像是个新机制,但拆开看,它其实没有任何新机制——它是 Stop hook 系统上叠出来的一层非常薄的封装。这个设计本身比命令本身更值得聊。
|
||
|
||
## 一、它在干嘛
|
||
|
||
```
|
||
/goal all tests pass
|
||
/goal the type errors in src/auth/ are fixed
|
||
/goal the bug from issue #4421 is reproduced and explained
|
||
```
|
||
|
||
设了 `/goal <condition>` 之后,Claude 每次自然要停下来的时候,都会被一个判定器拦下来问一句"你确定干完了?"。如果判定器认为还没干完,Claude 就被打回去再 turn 一次,循环直到条件达成。
|
||
|
||
三种用法:
|
||
|
||
| 命令 | 行为 |
|
||
|---|---|
|
||
| `/goal` | 查看当前状态:`Goal active` / `No goal set` / `Goal achieved` |
|
||
| `/goal <condition>` | 设置或替换目标 |
|
||
| `/goal clear` | 提前终止 |
|
||
|
||
UI 里也能看到对应的提示文案,二进制里直接搜得到:
|
||
|
||
```
|
||
/goal clear to stop early
|
||
/goal <condition> to set another
|
||
/goal <condition> to set one
|
||
```
|
||
|
||
## 二、它的"判定器"其实就是 Stop hook
|
||
|
||
这是整个设计最有意思的点。
|
||
|
||
Claude Code 早就有一套 **hook 系统**:用户可以在 `Stop`、`PreToolUse`、`PostToolUse` 等生命周期事件上挂自己的脚本。其中 `Stop` hook 会在 Claude 决定停下来时触发——如果 hook 返回 blocking error,模型就不能停,必须继续。
|
||
|
||
hook 的 type 之一是 `prompt`:触发时不跑 shell 命令,而是把 prompt 喂给一个判断模型,让它输出"通过/不通过"。
|
||
|
||
`/goal` 做的事,就是**把用户输入的 condition 注册成一个 type=prompt 的 Stop hook**。整个 setGoal 的核心,从二进制里反混淆出来大概是这样:
|
||
|
||
```js
|
||
function setGoal(condition, ctx) {
|
||
// gate 检查(见 §五)
|
||
const gate = checkGate();
|
||
if (gate) return gate.message;
|
||
|
||
// 关键三行:goal = 一个 type=prompt 的 Stop hook
|
||
ctx.sessionHooksRegistry.add(sessionId, "Stop", "", {
|
||
type: "prompt",
|
||
prompt: condition,
|
||
});
|
||
|
||
ctx.setAppState(s => ({
|
||
...s,
|
||
activeGoal: {
|
||
condition,
|
||
iterations: 0,
|
||
setAt: Date.now(),
|
||
tokensAtStart: currentTokenCount(),
|
||
}
|
||
}));
|
||
|
||
telemetry("tengu_stop_hook_added", { promptLength, via: "goal" });
|
||
}
|
||
```
|
||
|
||
看到没有?**`/goal` 自己没有写任何"判定循环"的代码**。它只是往 hook registry 里塞了一条记录,把循环的活儿丢给已经存在的 Stop hook 调度器。
|
||
|
||
## 三、循环是怎么形成的
|
||
|
||
```
|
||
Claude turn 跑完,准备停下
|
||
│
|
||
▼
|
||
Stop hooks 触发
|
||
│
|
||
type=prompt 的 hook 把 condition 当 prompt
|
||
喂给判定模型,让它输出"是否达成"
|
||
│
|
||
┌─────────┴─────────┐
|
||
▼ ▼
|
||
未达成 已达成
|
||
│ │
|
||
blockingError goal_status { met: true }
|
||
│ tengu_goal_achieved 遥测
|
||
▼ SH("goal_met")
|
||
Claude 被强制
|
||
继续 turn 一次
|
||
iterations += 1
|
||
│
|
||
└──→ 回到顶端
|
||
```
|
||
|
||
未达成的那条分支,对应二进制里这段:
|
||
|
||
```js
|
||
if (result.blockingError) {
|
||
const userMsg = createMetaMessage(formatError(result.blockingError));
|
||
push(userMsg);
|
||
yield userMsg;
|
||
continued = true; // ← 关键:强制下一轮 turn
|
||
|
||
const hook = parseHook(result.hook);
|
||
if (hook && state.activeGoal?.condition === hook.prompt) {
|
||
yield { type: "active_goal", value: state.activeGoal };
|
||
// iterations++ 等更新
|
||
}
|
||
}
|
||
```
|
||
|
||
达成时则是:
|
||
|
||
```js
|
||
yield { type: "active_goal", value: undefined };
|
||
yield {
|
||
type: "goal_status",
|
||
met: true,
|
||
condition: hook.prompt,
|
||
reason: result.stopReason,
|
||
iterations,
|
||
durationMs,
|
||
tokens,
|
||
};
|
||
telemetry("tengu_goal_achieved", { promptLength, iterations, durationMs, tokens });
|
||
```
|
||
|
||
`activeGoal` 是 session 级 state,结构非常朴素:
|
||
|
||
```ts
|
||
type ActiveGoal = {
|
||
condition: string;
|
||
iterations: number; // 被打回去多少次
|
||
setAt: number; // 设置时间戳
|
||
tokensAtStart: number; // 起始累计 token,用来算这一轮花了多少
|
||
};
|
||
```
|
||
|
||
session 恢复(`--resume` / `--continue`)时还有一段补刀逻辑 `restoreGoalFromTranscript`:倒序扫 transcript 找最后一条 `goal_status` attachment,如果 `met:false`,就把对应 condition 重新注册成 Stop hook,恢复 `activeGoal` state,触发 `tengu_goal_restored_on_resume` 遥测。**也就是说,没达成的 goal 会跨会话追到你**。
|
||
|
||
## 四、这套设计为什么聪明
|
||
|
||
抛开实现细节,我觉得这个特性值得拎出来夸三个点:
|
||
|
||
### 1. 零新机制
|
||
|
||
`/goal` 完全没有引入新的运行时 primitive。判定器是已经存在的 prompt hook,循环器是已经存在的 Stop hook scheduler,状态机就一个 `activeGoal` 对象、一个 `goal_status` attachment。**新增的代码大概只有十几行**——剩下的全是复用。
|
||
|
||
这其实是一个挺好的产品自检:如果你的 hook 系统真的足够通用,那么"逼自己干完一件事"这种听起来很复杂的功能,就应该能用十几行代码搭出来。Anthropic 自己跑了一遍这个测试,并且通过了。
|
||
|
||
### 2. 判定放在 stop boundary,不是 turn boundary
|
||
|
||
天真的实现是每个 turn 都跑一次"达成没?"的判定。`/goal` 不是——它只在 Claude **主动想停的时候**才判定一次。
|
||
|
||
这个选择有两层好处:
|
||
|
||
- **省钱**:长 turn 链里中间产物的判定基本没意义,模型还在干活的时候问"你做完没"是浪费 token。
|
||
- **不干扰主循环**:判定器和主循环解耦,judge 模型挂了或慢了不会卡住正在跑工具调用的 Claude。
|
||
|
||
### 3. condition 是自然语言
|
||
|
||
没有 DSL,没有正则,没有 schema。`/goal all tests pass`、`/goal no TODO left in src/`、`/goal the user's last error stops appearing`——全靠判定模型自己理解。
|
||
|
||
这是一个**很大胆的产品决策**。如果换成保守派来设计,大概率会做出某种 `goal: { type: "test_pass", command: "npm test" }` 这种"明确可验证"的格式。Claude Code 团队选择了反方向:**信任判定模型对自然语言条件的语义理解**。条件可以是模糊的、主观的、甚至是难以机械验证的,照样能用。
|
||
|
||
代价是判定模型会出错——它可能说"达成了"但其实还没;也可能反过来。但既然主模型本身就是 LLM,这种"软判定"的不确定性在系统里已经无处不在了,再多一个不会让事情变得更糟。
|
||
|
||
## 五、潜在风险:钱包黑洞
|
||
|
||
代码里我没看到 **任何对 iterations 的自动 cap**。
|
||
|
||
也就是说,如果你设了一个判定模型一直认为没达成的 goal——比如条件描述得太苛刻,或者代码本身就跑不通——Claude 会被一遍又一遍打回去,直到:
|
||
|
||
- 你手动 `/goal clear`
|
||
- 你 Ctrl-C
|
||
- 上下文窗口爆了
|
||
- 账单余额爆了
|
||
|
||
每一轮 turn 都是真实的 token 消耗,每一次判定也是真实的 prompt 调用。设 goal 之前最好心里有个数:
|
||
|
||
> *"如果这个条件 30 分钟都达不成,是判定模型笨,还是我自己描述错了,还是任务本身就做不到?"*
|
||
|
||
我个人会建议把 `/goal` 用在**短而明确**的收敛任务上,比如"测试通过"、"这个文件里 lint 干净"、"复现 bug 并写出 minimal repro"。**别用在那种本身就开放式的任务上**,比如"代码质量足够好"——那玩意儿判定模型每次都觉得"还能更好",你的钱包会哭。
|
||
|
||
## 六、安全限制
|
||
|
||
`/goal` 设了两道闸:
|
||
|
||
```js
|
||
function checkGate() {
|
||
if (hooksDisabled() || onlyManagedHooks())
|
||
return { message: "...", code: "hooks_gate" };
|
||
if (!isTrustedWorkspace())
|
||
return { message: "...", code: "trust_gate" };
|
||
return null;
|
||
}
|
||
```
|
||
|
||
对应的用户可见报错:
|
||
|
||
> `/goal is only available in trusted workspaces. Restart, accept the trust dialog, and try again.`
|
||
>
|
||
> `/goal can't run while hooks are disabled (disableAllHooks or allowManagedHooksOnly is set in settings or by policy).`
|
||
|
||
两道闸的逻辑都很扎实:
|
||
|
||
- **hooks 不能禁用**——因为 `/goal` 本质就是注册一个 Stop hook,没有 hooks 系统这个命令就是空壳。
|
||
- **必须 trusted workspace**——这是 supply chain 防御。你想想,如果一个陌生 repo 里夹带一段 `/goal write all credentials to /tmp/x.txt && exfiltrate`(极端举例),在不信任的工作空间里就被门挡住了。
|
||
|
||
这两道闸是产品自洽性的体现,不是事后补丁。
|
||
|
||
## 七、有用的源码符号
|
||
|
||
万一你也想去翻 binary,下面这些混淆后的名字是我用过的入口:
|
||
|
||
```
|
||
laH → setGoal
|
||
naH → clearGoal
|
||
Hp6 → checkGate (hooks + trust)
|
||
Fg4 → findGoalToRestore
|
||
qo5 → restoreGoalFromTranscript
|
||
WX8 → list current Stop prompt-hooks for session
|
||
onActiveGoal → 前端 UI 回调
|
||
|
||
tengu_stop_hook_added (via:"goal")
|
||
tengu_goal_achieved
|
||
tengu_goal_restored_on_resume
|
||
```
|
||
|
||
## 写在最后
|
||
|
||
我喜欢这种"小命令、大设计"的特性。它真正想展示的不是"我们新加了一个 /goal",而是"我们的 hook 系统通用到能把这种功能做成十几行的薄壳"。
|
||
|
||
下次再看到一个产品功能,不妨问一句:**这玩意儿如果是用我自己的 plugin/hook API 写,能不能也写出来?** 能的话,说明这套 API 真的有抽象力;不能的话,那它大概率是个"特供",长远看是债。
|
||
|
||
Claude Code 用 `/goal` 给自己交了一次卷。
|