跳转至

目标看守

"做完了没有"不由执行者说。判定者是一个只设目标、 只判定、不动手的角色:开跑前把需求确认书变成一份可判定的 清单,之后每一轮活结束独立判一次,产出一份判定 —— 达成就往下走,没达成就带着"差在哪"打回去接着做, 判成做不到就停下来问人。

解决什么问题

前置确认挡的是"做的不是想要的东西"。这一层挡的是另一类: "其实没做完,但它自己说做完了"。两件事必须分开,因为失败方式不同:

失败长什么样 什么时候暴露
需求错 每个产出都是照错的需求建的 几小时后,产出全废
完成度判断错 跑了一半的测试、改了一处漏了三处、"应该没问题" 你自己去用的时候

第二类为什么不能靠执行者自己把关:它有系统性的乐观偏差。 这不是它不老实 —— 是它看不见自己的盲区。它知道自己做了什么,不知道自己漏了什么。

所以判定交给一个没参与干活、跑在自己会话的角色。 它看到的只有目标和现场,不知道执行者试了多少次、有多辛苦,也就不会替它找理由。 这和确认者跑独立会话是同一个道理。

怎么用(最小代码)

零代码:命令行

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 用。

三个结论,不是两个

干活 ──> 判定 ──达成────> 往下走
              ├─未达成──> 打回,带上“差在哪”,续跑同一个会话接着做
              └─无法达成─> 停下来问人:接受 / 改目标 / 你判断错了

第三个结论是关键。只有"达成/未达成"的话,一个其实做不到的目标会让协调者 一轮一轮空转到额度见底 —— 那才是真的烧钱。所以判定者被明确要求: "再来一轮也没用"才叫无法达成(缺必要的外部条件、需求自相矛盾、判定项根本无法验证); 只是"还没做完"用未达成。

无法达成时,框架停下来问人:

  ? 目标被判为**无法达成**:缺少 X 依赖,判定项 2 无法验证
    怎么办?
     1) 接受这个结果,就这样往下走
     2) 修改目标
     3) 你判断错了,继续做
  • 接受 → 这一步就算过了,理由留在记录里
  • 修改目标 → 接着问你新目标是什么,追加到原目标后面(看得见改了什么),再来一轮
  • 你判断错了(以及你自己打的任何自由回答)→ 带上你的说法打回去,再来一轮

没人应答的时候会停,不会接着空转 —— 这是有意的。判定为做不到、又没人可问, 继续跑就是一轮一轮烧钱,而那正是最该避免的。停下时抛的是 StepAbort,原因写进 ctx["_aborted"],目标文件和 runs/manifest.json 都在,人回来接着决定。

"没做到"和"这里没法验"是两个结论

Verdict 的三个取值是 ACHIEVED / NOT_YET / UNREACHABLEUNREACHABLE 绝对不许判通过 —— 它走的是"停下来问人"那条路,不是"再来一轮"。 判定者写的"无法验证 / 没法验证 / 验证不了 / 无法判定 / unverifiable"全部归到 UNREACHABLE。把"这里没法验"当成"达成",等于用一句"看起来应该可以"把活收掉; 当成"未达成",则是让它一轮一轮重做一件本来就验不了的事。

判定含糊 = 未达成

Verdict.parse 的识别顺序:先按标题段取"结论 / 判定"那一段;没有标题段时, 整段是 1 / true 算达成,0 / false 算未达成(判定者被要求"只传 0/1"时, 很可能就真的只回一个数字);再不中就在结论文本里找关键词(长词在前);最后找孤立的 1 / 0

全都不中时 state 留空、okFalse,框架按未达成处理。 这一条是刻意的: "判定不出来"和"做完了"是两件事,含糊一律按未达成,并补一句默认理由 ("判定者没给出明确结论,按未达成处理")。

判的是产出物,不是源码

只读源码的判定是判不出交付物的

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 的 那一次,所以它避开了这个坑 —— 它的第一句话是"我不看那段回话下结论。去现场。"然后:

file cppide        → Mach-O 64-bit executable arm64
lsof -p 96040      → 起于 16:10,16:15 仍活着

判定 prompt 里那句话也是这个意思:自己去看现场,逐条对判定清单,看不到证据的判定项, 就是没通过

"打回"是接着做,不是重头做

打回用的是 Step.on_reject:下一轮 resume 刚被否掉的那个会话,prompt 换成判定反馈 (Verdict.feedback() 只给"差在哪",不给方案)。所以已经干完的活、读过的文件、走过的弯路 都还在上下文里,它只需要补差距。

区别写在步骤名里,runs/manifest.json 一眼能看出来:

干活            第一轮
干活#round2     被打回后接着做      ← on_reject 生效,resume 上一轮
干活#retry1     普通重试(重头跑)    ← 没有 on_reject 时的老行为

判定者自己则永远是新会话: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 照样发出了 WriteBash 调用,是权限层和路径安全把它们挡下的 ("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 —— 提问立刻落空,原因写进磁盘。

它不管需求对不对。 判定清单是从确认书推出来的,确认书错了,判定只会精确地验证一件错事。 那是前置确认那一层的事。