콘텐츠로 이동

목표 감시

잠깐 — 앵커는 원문 그대로 유지해야 한다. 다시 출력한다.

목표 감시

"다 됐는지"는 실행자가 말하지 않는다. 판정자는 목표만 세우고, 판정만 하고, 직접 손대지 않는 역할이다. 시작 전에 요구 확인서를 판정 가능한 체크리스트로 바꾸고, 이후 매 라운드 작업이 끝날 때마다 독립적으로 한 번 판정판정을 내놓는다 — 달성이면 다음으로 넘어가고, 미달성이면 "무엇이 부족한지"를 들려 되돌려 계속 시키고, 달성 불가로 판정되면 멈춰서 사람에게 묻는다.

무엇을 해결하는가

사전 확인이 막는 것은 "원하는 게 아닌 것을 만드는 일"이다. 이 층이 막는 것은 다른 종류다: "사실은 안 끝났는데, 스스로 끝났다고 말하는 일". 두 가지는 반드시 분리해야 한다. 실패 방식이 다르기 때문이다:

실패는 이렇게 생겼다 언제 드러나는가
요구가 틀림 모든 산출물이 틀린 요구 위에 세워져 있다 몇 시간 뒤, 산출물 전부 폐기
완료 판단이 틀림 절반만 돌린 테스트, 한 곳 고치고 세 곳 빠뜨림, "아마 문제없을 것" 당신이 직접 써볼 때

두 번째를 왜 실행자 스스로에게 맡길 수 없는가: 체계적인 낙관 편향이 있기 때문이다. 정직하지 않아서가 아니라 — 자기 사각지대를 볼 수 없어서다. 자기가 무엇을 했는지는 알지만, 무엇을 빠뜨렸는지는 모른다.

그래서 판정은 작업에 참여하지 않고, 자기 세션에서 도는 역할에 맡긴다. 그가 보는 것은 목표와 현장뿐이다. 실행자가 몇 번 시도했는지, 얼마나 고생했는지 모르니 대신 변명해 줄 일도 없다. 확인자를 독립 세션으로 돌리는 것과 같은 이치다.

어떻게 쓰는가(최소 코드)

코드 없이: 커맨드라인

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 / UNREACHABLE이다. UNREACHABLE은 절대 통과로 판정해서는 안 된다 — 그것은 "멈춰서 사람에게 묻기" 경로를 타지, "한 라운드 더" 경로가 아니다. 판정자가 쓴 "无法验证 / 没法验证 / 验证不了 / 无法判定 / 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 분기가 있으니 돌아갈 것"이라는 식의 추론은 받아들이지 않는다.

HT002judge_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에서 "설정 목표" 스텝의 판정자가 Bash를 11회 실행했는데, 당시 그의 무승인 목록에는 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이다 — 질문은 즉시 허공에 떨어지고, 이유는 디스크에 쓰인다.

요구가 맞는지는 관여하지 않는다. 판정 체크리스트는 확인서에서 도출된다. 확인서가 틀렸다면 판정은 틀린 일을 정확하게 검증할 뿐이다. 그건 사전 확인 층의 일이다.