目标看守¶
"做完了没有"不由执行者说。判定者是一个只设目标、 只判定、不动手的角色:开跑前把需求确认书变成一份可判定的 清单,之后每一轮活结束独立判一次,产出一份判定 —— 达成就往下走,没达成就带着"差在哪"打回去接着做, 判成做不到就停下来问人。
解决什么问题¶
前置确认挡的是"做的不是想要的东西"。这一层挡的是另一类: "其实没做完,但它自己说做完了"。两件事必须分开,因为失败方式不同:
| 失败长什么样 | 什么时候暴露 | |
|---|---|---|
| 需求错 | 每个产出都是照错的需求建的 | 几小时后,产出全废 |
| 完成度判断错 | 跑了一半的测试、改了一处漏了三处、"应该没问题" | 你自己去用的时候 |
第二类为什么不能靠执行者自己把关:它有系统性的乐观偏差。 这不是它不老实 —— 是它看不见自己的盲区。它知道自己做了什么,不知道自己漏了什么。
所以判定交给一个没参与干活、跑在自己会话里的角色。 它看到的只有目标和现场,不知道执行者试了多少次、有多辛苦,也就不会替它找理由。 这和确认者跑独立会话是同一个道理。
怎么用(最小代码)¶
零代码:命令行¶
flower # 默认就带目标看守
flower --no-goal # 关掉:干活跑完就算完
flower --rounds 5 # 最多五轮活(默认 3)
flower --judge-can-run # 让判定者能跑命令(判定更硬)
自己接线¶
两个函数各管一半,不要混:goal_step() 设目标(一个独立步骤), with_goal() 才是判定循环(把一个干活的步骤包起来)。
from pathlib import Path
from flower import HumanChannel, Step, Workbench, Workflow, clarify_step, goal_step, with_goal
wb = Workbench(Path.cwd()).ensure()
ch = HumanChannel(log_path=wb.notes / "问答记录.md") # 默认不限提问次数
goal_path = wb.notes / "目标.md"
work = Step("干活", spec=协调者, prompt=lambda ctx: f"照这个做:\n{ctx['确认需求']}")
wf = Workflow(channel=ch, workbench=wb, steps=[
clarify_step(ch, brief_path=wb.notes / "需求.md", prompt="帮我做一个 X"),
goal_step(ch, goal_path=goal_path),
with_goal(work, ch, goal_path=goal_path, rounds=3),
])
goal_step(channel, *, goal_path, ...):
| 参数 | 默认 | 说明 |
|---|---|---|
goal_path | — | 目标落在哪。放工作台的 notes/ 下,理由同确认书 |
brief_key | "确认需求" | 从 ctx 的哪个键读确认书。读不到就只拿到 "(没有确认书)" |
name | "设定目标" | 步骤名,也是 ctx 里的键名 |
spec / instructions | None / "" | 自带 AgentSpec,或给判定者追加领域指令 |
always_set | False | True = 每次重设 |
on_fail / retries | "stop" / 0 | 同 Step |
**spec_kw | — | 透传给 judge():can_run / model / effort / max_turns / max_budget_usd |
with_goal() 把一个干活的步骤包成带判定的循环:
with_goal(step, channel, *, goal_path, spec=None, rounds=3,
instructions="", can_run=False, name=None, **spec_kw)
rounds 是总轮数,不是额外轮数 —— 它落地成 retries = max(0, rounds - 1), 所以 rounds=3 最多跑三轮活,rounds=1 就是"跑一轮、判一次、判不过就失败"。 完整签名与字段语义见 Python API。
ctx 里会多出三个键:
ctx[GOAL_KEY] # "_goal" —— Goal 对象;ctx["设定目标"] 是它的 markdown
ctx[VERDICT_KEY] # "_verdict" —— 最近一次 Verdict,给 UI 用
ctx[ROUND_KEY] # "_goal_rounds" —— 跑了几轮
判定者是通过 ctx["_runtime"] 派出去的 —— Workflow.run 把运行时和事件出口都放进 ctx, 所以 gate 能自己起一个 agent,而判定过程照样打到你的 UI 上 (不然那十几秒界面全黑,看起来就像卡住)。
调不顺的时候先动这几个旋钮:
| 症状 | 拧哪个 |
|---|---|
| 判定太松,说达成其实没达成 | --judge-can-run 让它真跑一遍;或给 instructions 加领域判据 |
| 判定太严,一直打回 | 看 目标.md 的判定清单是不是写得比需求还高。改那个文件 |
| 一轮一轮空转 | 判定者该给"无法达成"却给了"未达成"。给它加指令说明什么算做不到 |
| 太贵 | --rounds 1,或 --no-goal 完全关掉 |
| 不想被打断 | --timeout 0:无法达成时不问人,直接停(理由留在磁盘) |
它实际做了什么¶
目标长什么样¶
goal_step 读确认书,输出两段,冻结到 .flower/notes/目标.md:
# 目标
让 conv.py 能把 md 转成 html。
# 判定清单
- 跑 `python conv.py a.md` 产出 a.html
- 输出里含 `<h1>`
- 列表被转成 `<ul><li>`
判定清单是这层的全部价值。 "实现完整"没法判定,"跑什么、看到什么"才能判定。 清单从确认书的「验收标准」来,但要被改写成每条都能当场验证的形式 —— 含糊的那条,判定者会补齐。 两段都非空(statement 有话、checks 非空)才算齐全,否则这一步不放行。
清单的长度由"有多少种失败方式"决定¶
不是由判定者有多严谨决定。git clone && make && ./app 这种任务,三到五条就够: 构建成功、跑得起来、能用。
实测栽过(HT002):一次"把仓库装起来跑"的任务被写成 15 条清单, 其中只有 5 条在验"东西能不能用",6 条在验"过程守规矩" (含查 ~/.zshrc 的 mtime、查 .flower/ 目录有没有被改 —— 那是框架自己的目录), 4 条原理上验不了。
边界不是判定项¶
这是那次的主要成因:
| 约束什么 | 怎么遵守 | |
|---|---|---|
| 边界 | 你怎么干活("只在项目目录内装""业务代码别动") | 靠不越界,不靠事后自证 |
| 判定项 | 交出来的东西("跑起来了没有""结果对不对") | 靠当场验证 |
把"没跑过 brew install"写成判定项,等于让每加一条边界就多一条检查 —— 而边界正是在确认阶段被鼓励写满的。真需要给个交代,一句话带过,不要拆成六条。
验不了的条目,设目标时就会喊¶
清单里标了 [此环境无法验证:原因] 的条目,goal_step 在冻结目标的那一刻就发一条提醒:
# 目标里有 4/15 条在这个环境里验不了 —— 判定时它们必然过不去,会停下来问你。
现在改 .flower/notes/目标.md 还来得及:
· 界面截图并实际看图 [此环境无法验证:屏幕录制未授权]
· ...
为什么要提前:这些条目的命运在设目标那一刻就定了,判定时必然过不去。 HT002 是先花了 $35.90 干活 + $1.40 判定,之后才发现的 —— 把发现提前到设目标那一步,同样的信息成本从 $37 降到 $0。
只提醒,不阻断:人可以选择就这样跑(HT002 最后就是选了"接受这个结果")。 Goal.unverifiable 是这份名单,事件 payload 里有结构化数据供 UI 用。
三个结论,不是两个¶
第三个结论是关键。只有"达成/未达成"的话,一个其实做不到的目标会让协调者 一轮一轮空转到额度见底 —— 那才是真的烧钱。所以判定者被明确要求: "再来一轮也没用"才叫无法达成(缺必要的外部条件、需求自相矛盾、判定项根本无法验证); 只是"还没做完"用未达成。
无法达成时,框架停下来问人:
- 接受 → 这一步就算过了,理由留在记录里
- 修改目标 → 接着问你新目标是什么,追加到原目标后面(看得见改了什么),再来一轮
- 你判断错了(以及你自己打的任何自由回答)→ 带上你的说法打回去,再来一轮
没人应答的时候会停,不会接着空转 —— 这是有意的。判定为做不到、又没人可问, 继续跑就是一轮一轮烧钱,而那正是最该避免的。停下时抛的是 StepAbort,原因写进 ctx["_aborted"],目标文件和 runs/manifest.json 都在,人回来接着决定。
"没做到"和"这里没法验"是两个结论
Verdict 的三个取值是 ACHIEVED / NOT_YET / UNREACHABLE。 UNREACHABLE 绝对不许判通过 —— 它走的是"停下来问人"那条路,不是"再来一轮"。 判定者写的"无法验证 / 没法验证 / 验证不了 / 无法判定 / unverifiable"全部归到 UNREACHABLE。把"这里没法验"当成"达成",等于用一句"看起来应该可以"把活收掉; 当成"未达成",则是让它一轮一轮重做一件本来就验不了的事。
判定含糊 = 未达成¶
Verdict.parse 的识别顺序:先按标题段取"结论 / 判定"那一段;没有标题段时, 整段是 1 / true 算达成,0 / false 算未达成(判定者被要求"只传 0/1"时, 很可能就真的只回一个数字);再不中就在结论文本里找关键词(长词在前);最后找孤立的 1 / 0。
全都不中时 state 留空、ok 为 False,框架按未达成处理。 这一条是刻意的: "判定不出来"和"做完了"是两件事,含糊一律按未达成,并补一句默认理由 ("判定者没给出明确结论,按未达成处理")。
判的是产出物,不是源码¶
只读源码的判定是判不出交付物的
HT001 里,验收标准的原文是"编译出一个独立可执行文件,在 macOS 终端直接运行",而判定只读到 Makefile:25-38 确有 Darwin 分支就判了通过 —— 交付的产物是 ELF 64-bit LSB pie executable, ARM aarch64, GNU/Linux。
判错的不是目标看守:那次运行还没有这个机制,判这一条的是协调者自己临时派出去的 独立审计员。但换成目标看守也一样会漏 —— 判定者默认 can_run=False,手里只有 Read / Glob / Grep,它跑不了 file,于是同样只能去读 Makefile,同样会 看见 Darwin 分支就判达成。这次失败要害不在"谁来判",在"拿什么证据判"。
这条教训被写进了 JUDGE_RULES:判的是产出物,不接受"源码里有 macOS 分支所以 应该能跑"这类推断。
HT002 是把 judge_can_run 打开、判定者真的去跑了 file / lsof 的 那一次,所以它避开了这个坑 —— 它的第一句话是"我不看那段回话下结论。去现场。"然后:
判定 prompt 里那句话也是这个意思:自己去看现场,逐条对判定清单,看不到证据的判定项, 就是没通过。
"打回"是接着做,不是重头做¶
打回用的是 Step.on_reject:下一轮 resume 刚被否掉的那个会话,prompt 换成判定反馈 (Verdict.feedback() 只给"差在哪",不给方案)。所以已经干完的活、读过的文件、走过的弯路 都还在上下文里,它只需要补差距。
区别写在步骤名里,runs/manifest.json 一眼能看出来:
判定者自己则永远是新会话:with_goal 的 gate 直接调 Runtime.run,不给 resume; 步骤名带轮次(干活·判定#1),而带后缀的名字不进跨进程血缘。 gate 里拿不到 ctx["_runtime"] 时抛 StepAbort,不假装通过。
跳过与重设¶
目标文件已存在且齐全时这一步跳过(和确认书一样)—— 长程 跑崩了重启,不该把前面的结论再算一遍。要重设就删掉那个文件,或者 always_set=True。
例外:唤醒时你又说了一句话。 那句话会追加进确认书,于是这一步重新推导 (always_set=True)。不重推的话判定者读的还是冻结的老清单,你新加的那件事做没做完 根本不进判定 —— 它会按老清单判"达成"。重推的代价实测是 $0.41 / 3 分钟。 见接续。
判定者能不能跑命令¶
默认不能。judge() 的免审批清单是提问工具加 Read / Glob / Grep, can_run=True 时才加 Bash。取舍:
- 给
Bash(CLI 的--judge-can-run)→ 能真的跑一遍验收命令,判定更硬 - 但它就能改动工作区了 → 它可能"顺手修一下"再判通过,那这次判定就没意义了
和确认者一样,没有 Write / Edit / Agent。执行这条的是 whitelist_guard 这道 hook, 不是 allowed_tools —— 后者是免审批清单,不是排他白名单,模型照样调得动不在里面的工具。 两条还成立的实测证据:HT002 里"设定目标"那个判定者跑了 11 次 Bash, 而当时它的免审批清单里根本没有 Bash;$0.1 的探针里, allowed_tools=["Read"] 的 agent 照样发出了 Write 和 Bash 调用,是权限层和路径安全把它们挡下的 ("requested permissions to write ... but you haven't granted it yet" / "Output redirection was blocked...")。今天这两种调用会被 hook 当场 deny —— 拦住它的是 hook,不是清单。
设目标的那个判定者默认拿不到 Bash
goal_step() 没有 can_run 形参,只能走 **spec_kw:goal_step(ch, goal_path=…, can_run=True)。 不显式给,它就没有 Bash,JUDGE_RULES 里"先 uname -a 看清楚你在哪"那条执行不了 —— 于是它可能给你写出一份在这台机器上根本验不了的清单。with_goal() 是另一回事, 它有独立的 can_run 形参(默认 False)。
为什么轮数有上限,而提问次数没有¶
提问几乎不花钱,一轮活是真金白银。所以:
- 提问不限次数(
max_asks=None)—— 问到清楚为止,由确认者自己判断 - 轮数有上限(
rounds=3)—— 但真正的兜底不是这个数字,而是第三个结论「无法达成」: 它一出现就停下来问人,不靠轮数耗完
什么时候不该用它¶
任务小到判定比干活还啰嗦。 这一层会把简单问题复杂化,而且是实测过的: HT002 那件"clone 一个仓库,在 macOS 上装好跑起来"的事,判定清单被写成 15 条, 其中 6 条在验过程守规矩、4 条原理上验不了;那一轮判定本身花了 $1.4037 / 37 轮 / 0.09h, 整次运行 $38.2409 / 0.97h。任务的失败方式本来就只有两三种时,--no-goal 更划算。
目标写不成可判定的清单。 探索性的活("看看这个仓库大概怎么回事")没有"做完了"的判据, 硬要设目标只会得到一份漂亮但判不动的清单。这种活用 flower once,或者 --no-goal。
关键那条判定项在这个环境里验不了。 判定者默认 can_run=False,工具只有 Read / Glob / Grep —— 它跑不了 file,只能去读源码。HT001 那条"在 macOS 终端直接运行"的验收标准,整个运行都在 Linux 容器里: 没有任何判定者能在容器内验证一个 macOS 二进制,不管它是自审还是独立的。 --judge-can-run 能救一部分(至少跑得了 file),救不了的那部分应该在设目标时 就标成 [此环境无法验证:…],让它走"停下来问人"那条路,而不是指望判定者变聪明。
无人值守且不允许中断。 判成无法达成又没人应答时,这一步会停,整条流程就到此为止。 要的是"跑完再说",那就 --no-goal;要的是"停下来但别等",那就 --timeout 0 —— 提问立刻落空,原因写进磁盘。
它不管需求对不对。 判定清单是从确认书推出来的,确认书错了,判定只会精确地验证一件错事。 那是前置确认那一层的事。