数据截至 (上游 commit b084ab075ba2)
质量闭环:第二个 agent 当评审、棘轮不许倒退
30 秒导读: 生成式设计工具最大的坑不是「生成不出来」,而是「生成出来的东西还行但不够好,用户也说不清哪儿不好」。Open Design 的答案是把评审变成产品的一部分:同一个 CLI 里让五个角色轮流打分、daemon 自己重算复合分不信模型自称的、正则 lint 把 AI 味儿的毛病翻译成下一轮 prompt、灰度开关只在连续 14 天达标时才往前推一格。
1. 这是什么(零基础也能懂)
- 一句话定义: 一套「生成完不算完」的机制——产物出来后由一场结构化评审打分、由静态检查复核、由可回归的门槛决定要不要再来一轮。
- 解决什么问题: 设计 agent 的 第一版产出通常「能看但平庸」。人来评审很贵且不稳定,所以让模型自己扮演评审团(设计师 / 挑刺者 / 品牌 / 无障碍 / 文案),用固定维度打分,凑不够分就再改一轮。
- 给谁用: 用 Open Design 做界面 / 海报 / slide 的人(无感知,看到的是一个"评审剧场"面板),以及要把这套东西推给全体用户的维护者(灰度与合规那半套是给他们的)。
这一章的四层结构。 从"跑一次"到"敢默认打开",中间隔着四道东西:
| 层 | 干什么 | 谁来判 |
|---|---|---|
| 评审编排 | 让一次生成变成"生成 + 打分 + 再改"的多轮 | 模型扮演的五人评审团 |
| 解析与写回 | 把模型吐的标签流变成事件、分数、磁盘上的产物 | daemon 的解析器(不信模型自称的分) |
| 静态体检 | 不花一个 token,正则扫出 AI 味儿的具体毛病 | lintArtifact 的规则表 |
| 合规与棘轮 | 决定这套东西对哪些人、在什么条件下默认打开 | 14 天滚动窗口的达标率 |
一个必须先说清的反直觉事实:没有第二个进程。 "第二个 agent 当评审"是人格层面的,不是进程层面的。评审跑在主 run 那一条 stdout 上——server 把同一个子进程的 stdout 喂给评审编排器(apps/daemon/src/server.ts:12524 的 if (critiqueShouldRun) 分支,里面 child.stdout 直接包成 stdoutIterable 交给 runOrchestrator),走完就 return,传统的单遍生成路径根本不执行。评审团是靠 prompt 里追加的一段"人格附录"变出来 的(正文由 apps/daemon/src/prompts/panel.ts:45 renderPanelPrompt 渲染,它怎么被叠进整份系统提示词见 提示词工厂)。
用起来什么样。 用户视角只是聊天窗右边多了一块面板:五条泳道逐个亮起、每条给一个分、一条折线告诉你这轮的复合分离及格线还差多少,右上角一个"Interrupt"按钮(按 Esc 也行)。跑不动的时候面板会诚实地说"这个 CLI 不会说这个协议"。
类比: 把它想成 CI。生成是 build,评审是 test,lintArtifact 是 linter,棘轮是"覆盖率不许降"的那条规则。区别只在于跑 test 的是同一个模型的另一副面孔。
2. 顶层全景(它大概怎么转)
怎么读这张图:从左到右是一次评审 run 的生命周期;虚线是"不经过模型"的旁路。
用户按回车
│
▼
┌────────────────┐ 带评审人格的 prompt
│ 主 run 起 CLI │───────────────────────────┐
└────────┬───────┘ │
│ 同一条 stdout │
▼ ▼
┌──────────────────┐ ┌───────────────────────────┐
│ ① 评审编排 │◀──────▶│ 灰度闸门 isCritiqueEnabled │
│ runOrchestrator │ │ (skill/项目/env/阶段 四级) │
└────────┬─────────┘ └───────────────────────────┘
│ 标签流
▼
┌──────────────────┐
│ ② 解析 parseV1 │──侧信道 onArtifact──▶ 写盘 artifact.<ext>
└────────┬─────────┘
│ PanelEvent 事件流
├────────────▶ SSE 双通道 ──▶ ⑥ 前端剧场
▼
┌──────────────────┐
│ 记分板 scoreboard │ daemon 自己算复合分
└────────┬─────────┘
│ 终态 + rounds
▼
SQLite critique_runs + transcript.ndjson(.gz)
旁路(不花 token):
产物 HTML ─▶ ④ lintArtifact ─▶ renderFindingsForAgent ─▶ 下一轮 prompt
多天统计 ─▶ ③ evaluateRollout ─▶ 推进/持平/回退 灰度阶段
整段对话 ─▶ ⑤ finalizeDesignPackage ─▶ DESIGN.md
部件一句话职责:
| 部件 | 干什么 | 主文件 |
|---|---|---|
| 评审编排器 | 驱动一次评审 run 从解析到落库,管超时/中断/终态分类 | apps/daemon/src/critique/orchestrator.ts |
| 协议解析器 | 把 <PANELIST>/<ROUND_END>/<SHIP> 标签流变成事件 | apps/daemon/src/critique/parsers/v1.ts |
| 记分板 | 加权复合分、及格判定、无 SHIP 时的兜底选轮 | apps/daemon/src/critique/scoreboard.ts |
| 产物写/读 | 原子写 artifact.<ext>,带路径穿越与符号链接防护的读端点 | artifact-writer.ts / artifact-handler.ts |
| 落库与恢复 | critique_runs 表、重启后把僵尸 running 行收尸 | apps/daemon/src/critique/persistence.ts |
| 中断链路 | 进程内 run 注册表 + HTTP 端点,把 AbortController 传下去 | run-registry.ts / interrupt-handler.ts |
| 合规harness | 拿录制的适配器输出跑一遍解析器,判 shipped/degraded/failed | apps/daemon/src/critique/conformance.ts |
| 棘轮 | 14 天窗口的推进/回退建议,纯函数 | apps/daemon/src/critique/ratchet.ts |
| 灰度解析 | 四级优先级决定这次 run 要不要走评审 | apps/daemon/src/critique/rollout.ts |
| 反 AI 味儿 lint | 正则规则表 + 把发现渲染成给 agent 的下一轮输入 | apps/daemon/src/lint-artifact.ts |
| 收口 | 把整段对话压成一份 DESIGN.md | apps/daemon/src/finalize-design.ts |
| 前端剧场 | SSE 订阅 + reducer + 五条泳道 UI | apps/web/src/components/Theater/ |
主线走一遍(高层):
- spawn 前先问闸门:这次 run 要不要评审(
isCritiqueEnabled)。要的话,prompt 里追加评审人格,critiqueShouldRun = true。 - CLI 启动,stdout 同时被评审编排器消费。模型按线协议吐
<CRITIQUE_RUN>→ 若干<ROUND>+ 五个<PANELIST>→<ROUND_END>→ 达标就<SHIP>。 - 编排器边解析边算分。模型自己写在标签属性里的 composite 只是建议,daemon 用自己的权重重算,两者差太多就报一条
composite_mismatch警告。 - 终态落库:产物写盘、transcript 落 ndjson、SQLite 行从
running改成终态、SSE 推给前端。 - 离线的两条旁路:
lintArtifact在保存产物时体检并把发现回喂;跨天的合规统计喂给棘轮决定灰度阶段。
3. 核心机制
3.1 评审编排:一次 run 怎么起、怎么死
它要解决的小问题: 一个可能跑几分钟、随时会卡住、随时会被用户掐掉的流式过程,怎么保证永远走到一个明确的终态并落库。
思路。 编排器把自己写成一个"总是有结局"的状态机:开头先插一行 status='running',中间无论出什么事,最后都必须 updateCritiqueRun 写进六种终态之一。
| 终态 | 什么时候 | 有没有兜底 ship |
|---|---|---|
shipped | 有 SHIP 且 daemon 重算后达标 | — |
below_threshold | 有 SHIP 但重算不达标,或压根没 SHIP 走兜底 | 有(selectFallbackRound) |
timed_out | 单轮或总时长超时 | 有(已完成的最好一轮) |
interrupted | 用户 abort,或子进程被信号杀掉 | 有 |
degraded | 解析器认定协议坏了 | 无 |
failed | 子进程非零退出,或编排器内部异常 | 无 |
真实入口是 runOrchestrator(apps/daemon/src/critique/orchestrator.ts:123)。它进门第一件事是逐个校验 config 的六个数值字段(orchestrator.ts:172-189),任何一个非有限数或越界就直接 RangeError——宁可在产生副作用前炸,也不要带着 maxRounds=NaN 跑一半。
超时与中断:一次 next() 同时和四件事赛跑
难点在于: 卡住的流不会 yield,所以不能"等下一块数据再检查超时"。做法是把每次 iterator.next() 包进一个 Promise.race。
// 示意,非源码:applyTimeouts 每轮迭代的赛跑结构
const races = [iter.next(), totalTimer.promise]; // 总时限,全程一个计时器
if (roundTimer) races.push(roundTimer.promise); // 单轮时限,每轮重建
if (signal) races.push(abortPromise); // 用户点了 Interrupt
if (childExitRace) races.push(childExitRace); // 子进程先死了
const result = await Promise.race(races); // 谁先到听谁的
真实实现是 applyTimeouts(orchestrator.ts:895),赛跑数 组组装在 orchestrator.ts:922-945。两个细节值得抄:
- 单轮计时器只在轮内存在。
roundDeadline在收到某轮第一个panelist_open时设,round_end时置回null(orchestrator.ts:314与orchestrator.ts:369)。轮与轮之间模型在思考,不该被单轮时限掐。 - teardown 也有超时。
finally里调用iter.return()时又套了一层 200ms 的 race(orchestrator.ts:960-965),防止一个卡死的 generator 把整个拆解流程钉住。
子进程退出被分成两类(orchestrator.ts:234-245):带信号退出(exitSignal !== null)抛 ChildSignaledError → 终态 interrupted;非零 code 抛 ChildExitError → 终态 failed;干净的 code 0 则返回一个永远 pending 的 Promise,让解析器自然读完。这个区分是有代价换来的:不区分的话,用户点取消导致的 SIGTERM 会掉进"没有 SHIP"的兜底分支,被记成 below_threshold——等于给用户的主动取消扣了一个分。
中断链路:三段接力
浏览器点 Interrupt / 按 Esc
│ POST /api/projects/:pid/critique/:rid/interrupt
▼
handleCritiqueInterrupt ← 校验 + 幂等 + 越权防护
│ registry.interrupt(pid, rid)
▼
RunRegistry(进程内 Map) ← 复合键 pid|rid
│ handle.abort.abort()
▼
AbortSignal → applyTimeouts 抛 AbortError → 编排器 flush 最好一轮 → 落 interrupted
- 注册表用复合键。
compositeKey(projectId, runId)拼成pid|rid(apps/daemon/src/critique/run-registry.ts:65),所有查询都要求两个 id 都对。这是在 HTTP 层已有的 projectId 校验之上的第二道防线:项目 A 的请求不可能误杀项目 B 里同名的 run。注册表故意不持久化(run-registry.ts:1-15的模块注释),daemon 重启后的残留由启动时的reconcileStaleRuns收尸,而不是试图恢复活的 AbortController。 - 幂等是设计出来的。 已经是
interrupted的行再来一次请求返回 202 而不是 409(apps/daemon/src/critique/interrupt-handler.ts:69-80) ,因为丢了响应的客户端重试不该看到状态从"接受"翻成"冲突"。其它终态仍返 409。 - "行说 running 但注册表里没有"这个洞被堵上了。 那是 daemon 刚重启、
reconcileStaleRuns还没觉得这行够旧的窗口期。不处理的话端点会撒谎:返回 202 但没人被杀。代码走恢复路径,直接把行标成interrupted并带recoveryReason='no_live_handle'(interrupt-handler.ts:114),响应里多一个recovered: true。
对应的落库函数 markRunInterruptedRecovery(apps/daemon/src/critique/persistence.ts:327)在 SQL 里带 AND status = 'running' 守卫,所以刚刚自己转成别的终态的行不会被覆盖。
适配器降级:把不会说协议的 CLI 关在门外
评审依赖模型能吐出结构化标签,但 25 家 CLI 不是每家都做得到。这里有两级过滤:
- 格式级,硬性。 只有
streamFormat === 'plain'的适配器有资格——25 家里正好 5 家(aider / deepseek / qwen / antigravity / grok-build,完整分布表见 适配 25 家 CLI §3.4)。这道闸拧了两遍:critiqueShouldRun本身就与上了isPlainAdapter(apps/daemon/src/server.ts:9419-9424),spawn 分支进门再查一次def.streamFormat,非 plain 就跳过编排 器、回落传统单遍生成,并对每种格式打一次性警告(server.ts:7417-7423)。理由是解析器只认裸 stdout,喂它包装字节等于自找MalformedBlockError。 - 健康级,带 TTL。
apps/daemon/src/critique/adapter-degraded.ts是一张进程内的"病号表":markDegraded(adapterId, reason, ttlMs)打标(adapter-degraded.ts:49),默认 24 小时(ADAPTER_DEGRADED_DEFAULT_TTL_MS,adapter-degraded.ts:42)。读的时候顺手清过期项——getDegradedEntry发现过期就store.delete再返回 null(adapter-degraded.ts:94-102),所以调用方不需要单独跑淘汰。
设计取舍写在文件头:v1 只做进程内 Map,持久化推到后续阶段,但把 markDegraded / isDegraded 的签名先定死,将来换存储层是原地替换。
3.2 解析与写回:从标签流到磁盘
它要解决的小问题: 模型输出是流,标签会被切在任意位置;同时模型可能撒谎(自称的分对不上)、可能超量(吐一个 20MB 的 HTML)、可能在产物里嵌套跟协议同名的字符串。
事件流的形状
解析器 parseV1(apps/daemon/src/critique/parsers/v1.ts:50)是个 async generator,吐 出扁平的 PanelEvent:
| 事件 | 何时 | 携带 |
|---|---|---|
run_started | <CRITIQUE_RUN> | 协议版本、评审阵容、及格线、分制 |
panelist_open / panelist_close | 每个 <PANELIST> 的头尾 | 角色、该角色总分 |
panelist_dim | 块内每个 <DIM> | 维度名、维度分、点评 |
panelist_must_fix | 块内每个 <MUST_FIX> | 必改项文本 |
round_end | <ROUND_END> | 模型自称的 composite / must_fix / decision |
ship | <SHIP> | 轮次、自称 composite、摘要、产物引用 |
parser_warning | 软违规 | kind(score_clamped / unknown_role / duplicate_ship / composite_mismatch) |
主循环的核心是 drain(v1.ts:109):拿 buffer 从头匹配已知标签,匹配不上又是 < 开头就 break(等更多字节),匹配不上又不是空白且在 run 内就判 MalformedBlockError。每处理完一块就 state.buf = state.buf.slice(cursor),剩下的留给下一 chunk。
分数被夹在申明的分制里。 <CRITIQUE_RUN scale="10"> 会把 state.scoreScale 设成 10(v1.ts:121-122),之后 clampScore / isOutOfRange(v1.ts:714-724)按这个 scale 判断。一个 scale=10 的 run 里出现 42 分会被夹到 10 并吐一条 score_clamped 警告,而不是让 42 去污染复合分。
信封守卫是三重的。 <ROUND> / <PANELIST> / <ROUND_END> / <SHIP> 出现在 <CRITIQUE_RUN> 之前一律 MalformedBlockError;<PANELIST> 出现在有效 <ROUND n> 之前也炸(v1.ts:228-233);<SHIP> 在任何 <ROUND_END> 之前出现同样炸(v1.ts:342-347),因为那会绕过"第 1 轮设计师必须交产物"的不变量(v1.ts:293-297)。
产物走侧信道,绝不上 SSE 总线
为什么: ship 这个 PanelEvent 同时也是 SSE 的线上形状。如果把几百 KB 的 HTML 塞进去,每个订阅者都会收到一份——总线直接废掉。
做法是一个回调:解析器在 yield ship 事件之前同步调用 opts.onArtifact({ round, mime, body })(v1.ts:440-446),编排器把它接进一个盒子(orchestrator.ts:216 的 artifactBuffer),事件本身只带一个小小的 artifactRef: { projectId, artifactId }。
于是就有了这条顺序不能乱的三步(orchestrator.ts:488-545):
写文件 artifact.<ext> → updateCritiqueRun 把 artifactPath 钉在行上 → 才 emit critique.ship
倒过来的话,前端一收到 ship 就去请求 /artifact,而那行的 artifactPath 还是 null,用户拿到 404。
CDATA:协议自指的经典陷阱
产物可以是 HTML/JS,而 HTML/JS 里完全可能出现字面量 </SHIP> 或 </ARTIFACT> 或 <SUMMARY>…</SUMMARY>。朴素的 indexOf 会把块切错位置。
解法是两个 CDATA 感知的工具函数:
indexOfOutsideCdata(source, needle)(v1.ts:651)——扫描时遇到<![CDATA[就整段跳到]]>之后再继续找。依据是 XML 规范禁止 CDATA 内容里出现字面]]>,所以第一个终结符一定是真的。extractArtifactBlock(source)(v1.ts:651)——如果产物体以<![CDATA[开头,就找]]>+ 可选空白 +</ARTIFACT>这个组合,而不是第一个裸的</ARTIFACT>。返回值里带一个blockEnd偏移。
blockEnd 存在的唯一理由是:<SUMMARY> 的扫描必须限定在产物闭合之后的字节里(v1.ts:421-425),否则一段 CDATA 包着的 HTML 里如果有 <SUMMARY> 元素,会把评审摘要劫持成产物字节。
三道尺寸闸门
| 闸门 | 位置 | 拦什么 |
|---|---|---|
| 单块字节上限 | v1.ts:179 / 262 / 341(每种块进门就查) | 一整块超大内容在一个 chunk 里到达 |
| buffer 字节上限 | v1.ts:89-95(每个 chunk drain 完后查) | 一个永远不闭合的失控块 |
| 写盘上限 | writeShipArtifact 的 maxBytes(artifact-writer.ts:101) | 落盘前最后一道 |
三处都用 Buffer.byteLength(x, 'utf8') 而非字符串 .length——一 buffer 的中日韩文字或 emoji 能在字符数没超的情况下远超字节上限。
写盘与读盘:都当环境不可信
writeShipArtifact(apps/daemon/src/critique/artifact-writer.ts:90):mime 走一张窄白名单(ARTIFACT_MIME_EXTENSIONS,artifact-writer.ts:17,只有 html/css/md/txt/json/svg),不认识的一律落成 .bin + application/octet-stream;写法是先写兄弟临时文件再 rename(artifact-writer.ts:114-117),避免崩溃时留下半个 artifact.html。
读端 handleCritiqueArtifact(apps/daemon/src/critique/artifact-handler.ts:41)的防护更密:
- 跨项目泄露守卫,返 404 而非 403(
artifact-handler.ts:82-87),不泄露别的项目有没有这个 run。 - 路径穿越守卫:行里的
artifactPath必须 resolve 到配置的 artifacts 根之内(artifact-handler.ts:112-123),被篡改的 DB 行读不出任意文件。 - 只 open 一次,带
O_NOFOLLOW,然后对已打开的 fd 做 stat(artifact-handler.ts:139-161)。老写法是lstat(path)校验完再createReadStream(path),中间留了一个 TOCTOU 窗口,本地有写权限的人能在两步之间换成符号链接。 - SVG / HTML 的响应额外挂
default-src 'none'的 CSP(artifact-handler.ts:183-188),给绕过沙箱 iframe 直接访问 URL 的客户端兜底(沙箱预览那半套见 产物与沙箱预览)。
transcript 与落库
writeTranscript(apps/daemon/src/critique/transcript.ts:31)把事件序列以 ndjson 流式写盘,按累计 UTF-8 字节数(不是数组长度)决定要不要 gzip,阈值 256 KiB(transcript.ts:14 与 transcript.ts:94)。gzip 路径先写 .gz.tmp、fh.sync() fsync、再 rename(transcript.ts:100-115),因此崩溃永远不会留下一个零字节的合法命名 .gz。逆操作是 readTranscript(transcript.ts:149),replay 路径用它。
落库那层(apps/daemon/src/critique/persistence.ts)有两个不显然的点:
rounds_json这一列存两种形状:没有恢复原因时存裸数组,有的时候存{ rounds, recoveryReason }信封(persistence.ts:88-99),读取端parseRoundsPayload两种都吃、坏 JSON 返空数组(persistence.ts:101-121)。用一个已有列承载可选元数据,省掉一次 schema 迁移。- 启动时的
reconcileStaleRuns(persistence.ts:356)在一个事务里把所有超期的running行改成interrupted并盖上recoveryReason='daemon_restart'。server 在 boot 时调用它,staleAfterMs直接取critiqueCfg.totalTimeoutMs(apps/daemon/src/server.ts:2984)。
daemon 才是记分的权威
这是整章最重要的一条设计决定。模型在 <ROUND_END composite="9.1"> 里自称的分,只当建议:
模型自称 composite ──┐
├──▶ 差值 > 0.01 ? ──▶ 发 composite_mismatch 警告
daemon 重算 composite ┘ │
(由 panelist_close 事件 └──▶ 无论如何,用 daemon 那个值落库和判决
按配置权重算)
重算发生在 panelist_close 分支(orchestrator.ts:329 调 computeComposite),比对与告警在 orchestrator.ts:363-366,容差常量 COMPOSITE_TOLERANCE = 0.01(orchestrator.ts:52)。
权重表来自 defaultCritiqueConfig()(packages/contracts/src/critique.ts:58-72):
| 角色 | 权重 | 备注 |
|---|---|---|
| designer | 0 | 出活的人不给自己算分 |
| critic | 0.4 | 挑刺者占最大头 |
| brand | 0.2 | |
| a11y | 0.2 | |
| copy | 0.2 |
默认及格线 8.0 / 分制 10 / 最多 3 轮。computeComposite(apps/daemon/src/critique/scoreboard.ts:27)在有角色缺席时把权重按现存角色等比重分,而不是当 0 分算——少一个评审不应该等于挨了一顿差评。
及格判定 decideRound(scoreboard.ts:51)是两个条件的与:复合分过线(带 1e-9 浮点容差)且 must-fix 数为 0。也就是说任何一条"必改"都能一票否决一个高分。
权威性还体现在拒收伪造的 SHIP。 如果模型给一个 daemon 从没关闭过的轮次发 SHIP,编排器直接丢弃它、发一条 duplicate_ship 警告、掉进无 SHIP 的 兜底路径(orchestrator.ts:437-448)。
没有 SHIP 时的兜底由 selectFallbackRound(scoreboard.ts:69)按策略选:ship_best(最高分,同分取轮次大的)/ ship_last / fail(返回 null,终态 failed)。默认 ship_best。
3.3 合规与棘轮:分数只许上行
它要解决的小问题: 这套功能默认关着(M0 暗发布)。要把它对所有人打开,得有一个不靠拍脑袋的依据,而且出问题时要能自动往回退。
第一步:单次跑分 runAdapterConformance
apps/daemon/src/critique/conformance.ts:139 拿一段 AsyncIterable<string>(合成 fixture 或录制的真实输出)过一遍解析器,产出一个分类。注意这里判的是"这家 CLI 会不会说协议",不是"设计好不好看"——设计维度的打分在评审团那一侧(上一节的权重表)。
规则表,从上往下第一条命中的赢:
| # | 条件 | 结论 |
|---|---|---|
| 1 | 解析器抛 Malformed / Oversize / MissingArtifact | degraded,原因即错误名 |
| 2 | 源抛其它错 | failed / unexpected_error |
| 3 | 流里任何位置出现过 parser_warning | degraded / parser_warning |
| 4 | 有 SHIP,但发货那一轮没凑齐阵容 | degraded / incomplete_panel |
| 5 | 有 SHIP、阵容齐、零警告 | shipped |
| 6 | 流结束还没 SHIP | failed / no_ship |
三个抠出来的细节:
- 看到 SHIP 不能立刻返回。 解析器的
duplicate_ship警告是在第一个 ship 事件 yield 之后才发的,所以 harness 必须把流排干再分类(conformance.ts:230-248只记录第一个 ship,循环继续)。 - 阵容按轮分桶。
closedRolesByRound是Map<round, Set<role>>(conformance.ts:155),发货轮必须自己凑齐阵容,不能借上一轮的panelist_close冒充完整(conformance.ts:286-291)。 - 规则 3 排在规则 6 前面是刻意的。 一个发了警告然后直接死掉(没 SHIP)的适配器,应该被记成"协议脏"而不是"干净但没跑完"。顺序反了的话,脏适配器反而不会被打标(
conformance.ts:267-279的注释把这个反转说得很清楚)。 - 协议版本不匹配当场判死。 看到
run_started.protocolVersion !== CRITIQUE_PROTOCOL_VERSION立刻markDegraded并返回(conformance.ts:184-192):解析器不知道未来版本增删了哪些字段,一个"看起来合法"的 SHIP 反而更危险。
分类为 degraded 时会顺手往病号表打标(conformance.ts:302-314 的 mark),本地的两个原因(parser_warning / incomplete_panel)先经 toContractReason 映射回线上的枚举(conformance.ts:123-129),免得注册表里出现契约不认识的值。
第二步:跨天存档
apps/daemon/src/critique/conformance-history.ts 把每天每个适配器一行 ConformanceDay 追加到 <dataDir>/conformance/<adapter>/<date>.jsonl。选型理由写在文件头,三条都是运维视角:
- 每适配器每天一个文件 → 一个文件坏了不会毒到别的适配器/别的天。
- JSON-lines 而不是单个 JSON blob → cron 中途被打断留下的是"可恢复"的文件,读端
readConformanceHistory(conformance-history.ts:78)对解析失败的行直接丢弃而不是抛。 - 目录按适配器分 → "清掉适配器 X 的全部历史"是一次
rm -rf。
写入不做去重(appendConformanceDay,conformance-history.ts:57),改由读端保留每个 (adapter, date) 的最后一条(conformance-history.ts:109-129)——重试的 cron 因此自动得到正确答案,代价是不用做随历史增长的读改写。
第三步:棘轮 evaluateRollout
apps/daemon/src/critique/ratchet.ts:137,纯函数,无 IO。输入是一窗历史,输出三选一:
┌─────────────────────────┐
14 天窗口的 │ 任何一天 shipped 率 │ 是
每日舰队聚合值 ──▶│ < demoteFloor(=阈值/2)? │────▶ demote 退一格
└───────────┬─────────────┘
│ 否
▼
┌─────────────────────────┐ 是
│ 全部 14 天都双线达标? │────▶ promote 进一格
└───────────┬─────────────┘
│ 否
▼
hold(含数据不足 / 差一点 / 已到顶或底)
四条值得学的工程习惯:
- 入口先防御参数。
windowDays <= 0会让passingDays >= windowDays在 0 >= 0 时恒真——一个零证据的推进。所以非法的 window / 阈值一律返回hold并说明原因(ratchet.ts:149-175)。 - 舰队聚合是加权平均。 每个适配器按自己的
totalRuns加权进分子分母(ratchet.ts:193-197),而不是把各家的比率做简单平均——否则跑了 3 次的小适配器和跑了 300 次的主力同权。 - 退回门槛低于推进门槛。
demoteFloor = shippedThreshold / 2(ratchet.ts:205)。目的写在文档注释里:单独一天掉到 0.88 不该把灰度弹回去,退回信号是留给持续性崩坏的。 - 缺数据 ≠ 失败。 某天完全没有行时
continue(ratchet.ts:207-208),最后归到hold的"insufficient data"分支。默认阈值 0.90 shipped / 0.95 clean-parse / 14 天,都可覆盖。
出口是 GET /api/critique/conformance(apps/daemon/src/routes/daemon.ts:184-199):读历史 → 喂棘轮 → 返回 { window, decision }。它只给建议,不动开关;真正翻 OD_CRITIQUE_ROLLOUT_PHASE 是运维的事。
第四步:灰度开关本身
isCritiqueEnabled(apps/daemon/src/critique/rollout.ts:84)是四级优先级,第一条命中即停:
| 优先级 | 条件 | 结果 |
|---|---|---|
| 1 | skill 声明 opt-out | false(一票否决,env 也压不过) |
| 1 | skill 声明 required | true |
| 2 | 项目级覆盖非 null | 取项目值 |
| 3 | 环境变量 OD_CRITIQUE_ENABLED 非 null | 取 env 值 |
| 4 | 阶段 M0 / M1 | false |
| 4 | 阶段 M2 | 只对 opt-in 的 skill 为 true |
| 4 | 阶段 M3 | true |
三个输入各有一个专职的窄函数,都是"看不懂就当没设置":
- skill 那一路 是
normalizeCritiquePolicy(apps/daemon/src/skills.ts:697):只认 trim + 小写后的required/opt-in/opt-out,其余(包括拼错的)一律null落到下一级。它被listSkills()在解析 SKILL.md frontmatter 时调用(skills.ts:359与skills.ts:404),资产扫描那套见 文件系统即产品。 - 项目那一路 是
narrowProjectCritiqueOverride(apps/daemon/src/critique/spawn-inputs.ts:34)。整个函数只有四行,但被单独拎出来成文件是有理由的:项目 metadata 是自由 JSON blob,经 SQLite 往返后类型不可信。这里只认真正的 boolean,字符串'true'、数字1、null、嵌套对象通通塌成null。文件头把这条性质点名为"承重的安全属性:一次损坏的 metadata 写入绝不能意外把功能打开"。 - env 那一路 是
parseEnvEnabled(rollout.ts:135),未设置返回null而不是false——"没说"和"说了不要"必须是两件事,否则第 4 级的阶段默认永远不会生效。阶段解析parseRolloutPhase(rollout.ts:116)遇到未知值退回M0,全新安装绝不会被意外打开。
调用点在 spawn 前(apps/daemon/src/server.ts:9383-9389),随后与另外四个条件求与得到 critiqueShouldRun(server.ts:5146-5151):闸门通过 且 有品牌 且 有 skill 且 不是媒体面板 且 是 plain 适配器。这个合成量同时决定"prompt 里加不加评审人格"和"走不走编排器",两边必须严格同步——否则要么模型被告知要吐标签却没人解析,要么解析器在等一堆模型压根没被要求发的标签。
3.4 不靠模型的静态体检
它要解决的小问题: 有些毛病不需要评审团、不值得花 token,而且模型自己评自己时经常看不见——因为那正是它的默认审美。
lintArtifact(apps/daemon/src/lint-artifact.ts:120)是一堆正则检查,故意不解析 HTML。文件头把取舍写明:便宜、确定、易扩展,误报可接受,因为每条发现都带原文片段供 agent 自己复核。
检查项目录(severity / id / 抓什么):
| 级别 | id | 抓的东西 |
|---|---|---|
| P0 | purple-gradient | 渐变里出现紫/靛系 hex 或 purple/violet 关键字 |
| P0 | trust-gradient | 蓝→青两段式"信任渐变"(SaaS hero 陈词滥调) |
| P0 | ai-default-indigo | 单独用 Tailwind 靛蓝作强调色——"最被举报的 AI 痕迹" |
| P0 | emoji-icon | ✨🚀🎯 之类当功能图标用在标题/按钮/列表项里 |
| P0 | left-accent-card | 圆角卡片 + 彩色左边框这一经典组合 |
| P0 | sans-display | h1/h2/h3 的 display face 退回 Inter/Roboto/system-sans |
| P0 | invented-metric | "10× faster"、"99.9% uptime" 这类编造指标 |
| P0 | filler-copy | lorem ipsum / "Feature One" / placeholder text |
| P0 | scroll-into-view | Element.scrollIntoView()——跨 iframe 会把宿主页拽走 |
| P0 | slide-theme-missing | deck 形态里 .slide 缺 light/dark 主题类 |
| P1 | all-caps-no-tracking | text-transform: uppercase 没配 ≥0.06em 字距 |
| P1 | external-image | unsplash / placehold.co 等外部占位图 CDN |
| P1 | raw-hex | :root 之外裸 hex 超过 12 个(token 没被遵守) |
| P1 | accent-overuse | body 里 var(--accent) 内联用超过 6 次 |
| P1 | slide-rhythm | 连续三张同主题幻灯片 |
| P2 | missing-section-anchor | <section> 缺 data-od-id / data-screen-label |
这份表最有意思的不是规则本身,而是为了不误伤而堆的那层"CSS 半解释器"。以 all-caps-no-tracking 为例:
- 先剥掉 HTML 注释和 CSS 注释(
lint-artifact.ts:127与lint-artifact.ts:327)——被注释掉的示例代码不该报错。 - 规则体的正则用
[^{}]*而不是[^}]*(lint-artifact.ts:342),这样只匹配最内层规则,@media包裹不会被当成一条选择器吞掉。 - 字距合规判断
hasAdequateUppercaseTracking(lint-artifact.ts:637)会把var(--x)按 token 表解析成字面值再判;单位em直接和 0.06 比,rem/px换算成绝对 px 后与同规则内的font-size × 0.06比;同规则里声明了无法解析单位的font-size时拒绝走宽松兜底(lint-artifact.ts:670),因为标题可能任意大。 - token 表按主题分桶(
buildResolvedThemes,lint-artifact.ts:695)。老实现把所有 token 按名字合并再做笛卡尔积,会造出(默认主题的字号, 暗色主题的字距)这种现实中不存在的组合,在正常的明暗双主题产物上误报。现在同一 scope 内声明的值始终配对评估,且每个主题都得达标才 算通过。 ai-default-indigo的逃生舱只留给--accent:stripTokenBlocks(lint-artifact.ts:893)会把纯 token 形状的全局主题块整块摘掉再扫,但declarationLaundersIndigo(lint-artifact.ts:935)会把:root { --primary: #6366f1 }这种"换个名字洗白靛蓝"的块留在扫描范围里。
回喂才是闭环的那一笔。 renderFindingsForAgent(lint-artifact.ts:519)把发现排序(P0 优先)后渲染成一段可直接塞进 system reminder 的 Markdown:
<artifact-lint>
The artifact you just produced has the following anti-slop / design-token issues.
2 P0 (must fix), 1 P1 (should fix), 0 P2 (nice to have).
Re-emit a corrected `<artifact>` in your next turn — do not write a separate
explanation; the user has the previous version already.
**[P0] ai-default-indigo** — Found a default LLM accent color (#6366f1) …
Fix: Replace with var(--accent) from the active DESIGN.md. …
Snippet: `#6366f1`
</artifact-lint>
三条能直接抄的写法:先给统计(模型知道要修几个)、明确禁止写解释("用户已经有上一版了")、每条都带 fix 和原文片段(不是"你错了"而是"改成这样")。
两个 HTTP 出口都在 apps/daemon/src/routes/project/index.ts:保存产物时顺带 lint 并把 findings 一起返回给 UI 画角标(index.ts:1882),以及独立的 POST /api/artifacts/lint 直接返回 findings + agentMessage(index.ts:1902-1905),聊天层用它在写盘前就把问题捅回去。
3.5 收口成资产:把一场对话压成一份 DESIGN.md
它要解决的小问题: 评审过了、产物有了,但知识还散在几十轮聊天里。新来的人(或下一个 LLM)不该靠回放整段对话来重建上下文。
finalizeDesignPackage(apps/daemon/src/design/finalize-design.ts:263)是一次性的合成:transcript + 设计系统 + 当前产物 → 直连 provider → 写出 <projectDir>/DESIGN.md。
四个输入怎么凑齐:
transcript (SQLite 导出 .jsonl) ──truncateTranscriptForPrompt──┐
设计系统 DESIGN.md 正文 ────────────────────────────────────────┤
当前产物(活动 tab → 最新 sidecar → null)──resolveCurrentArtifact─┤──▶ buildSynthesisPrompt
生成上下文(时间/项目 id/消息数)───────────────────────────────┘
resolveCurrentArtifact(finalize-design.ts:158)的三级回退:① 活动 tab 且磁盘上真有.artifact.json边车;② 所有带边车的文件里按manifest.updatedAt降序取最新(无 updatedAt 的排最后);③ null。tab 名在拼进路径之前先过validateProjectPath(finalize-design.ts:182),拦住../../../etc/passwd之类;校验失败不是中止 finalize,而是退到第二级。truncateTranscriptForPrompt(finalize-design.ts:936)保头保尾丢中间:超过 384 KiB 就保留 header 行、按对半的字节预算各留一段头和尾,中间插一行哨兵{"kind":"truncated","reason":"size","omittedBytes":N}。omittedBytes是真实字节差,下游能看出缺了多少。磁盘上的 transcript 不动,截断只活在 prompt 里。buildSynthesisPrompt(finalize-design.ts:889)对缺失输入给显式占位:没选设计系统就写(no design system selected for this project),没产物就写(no artifact in scope for this finalize)。system prompt(finalize-design.ts:836-870)钉死七个 Markdown 标题和一条硬规则——"输入缺失时对应章节要明说,而不是编"。
并发与原子性。 进门先用 fs.openSync(lockPath, 'wx') 抢 .finalize.lock,EEXIST 就抛 FinalizePackageLockedError(finalize-design.ts:299-309);写文件走 writeFileSync({flag:'wx'}) → 重开 fsync → rename(finalize-design.ts:420-437),失败则 unlink 临时文件。锁在 finally 里释放。
直连 provider 的重试姿态是刻意保守的。 callFinalizeProviderWithRetry(finalize-design.ts:660)只在 429 或 5xx 上重试一次,退避固定 1 秒;终态失败抛 FinalizeUpstreamError,带上游 HTTP 状态和原始 body 供路由层做 AUTH_FAILED / RATE_LIMITED / UPSTREAM_FAILED 的映射与脱敏。注释直说:这是"意见",理由是 daemon 其它同步端点一次都不重试,/finalize 是阻塞式的,不该让用户等两轮退避。callAnthropicWithRetry(finalize-design.ts:698)现在只是钉了 protocol 的薄壳,老调用点不用改。
超时是双保险:总是建一个 120s 的 AbortController,调用方另给的 signal 用 AbortSignal.any 合并而不是替换(finalize-design.ts:355-370)。曾经的写法是"有调用方 signal 就用它",结果把超时给关掉了。
响应解析按协议分叉。 extractDesignMd(finalize-design.ts:712)认四种形状:Anthropic 的 content[].text、OpenAI/Azure 的 choices[].message.content、Gemini 的 candidates[].content.parts[].text、Ollama 的 message.content。任何意外形状(含 200 但非 JSON、抽出来是空串)都抛 FinalizeUpstreamError(502)——宁可报错,也不要在磁盘上留一个空的 DESIGN.md。
前端两块。 buildFinalizeRequest(apps/web/src/lib/resolve-finalize-request.ts:45)从 AppConfig 里挑出该协议的 BYOK 字段,缺 key 或缺 model 就返回 null;配套的 buildFinalizeCredentialsMissingToast(resolve-finalize-request.ts:65)在 daemon 模式下会明说"本地 CLI 登录只管聊天,finalize 需要 BYOK"——把一个容易困惑的边界翻译成人话。
useFinalizeProject(apps/web/src/hooks/useFinalizeProject.ts:60)管请求生命周期,三个细节:
- 前端超时 130s = daemon 的 120s + 10s 缓冲(
useFinalizeProject.ts:27),故意让 daemon 的超时先触发,这样用户看到的是有意义的错误码而不是一个裸的 abort。 - 超时 abort 和用户点取消用
timedOutRef区分(useFinalizeProject.ts:148-164):前者报 TIMEOUT 并提示"daemon 可能还在跑",后者干净地回 idle。 - 每个写 state 的地方都先过
isCurrent()(useFinalizeProject.ts:101):双击按钮时,第一次请求迟到的 AbortError 不能把第二次请求的 pending 状态清 掉。
4. 前端的评审剧场
它要解决的小问题: 评审要跑几分钟。什么都不显示,用户会以为卡死了。
数据流是一条直线:
daemon 双通道发 SSE 浏览器
├─ /api/runs/:runId/events ┐
└─ /api/projects/:pid/events ┴──▶ EventSource
│ 11 个 critique.* 频道
▼
sseToPanelEvent ← 频道名定 type + isPanelEvent 严格校验
│ PanelEvent = reducer action
▼
useCritiqueStream (useReducer)
│ CritiqueState(6 个 phase)
▼
TheaterStage → 泳道 / 折线 / 中断按钮
为什么是双通道。 Theater 挂载点是项目作用域的(跟着用户在项目工作区里走,跨 run 存活),而不是 run 作用域的。所以编排器的 bus 把每个事件同时推给 run 级的 send(...) 和项目级的 emitProjectEvent(...)(apps/daemon/src/server.ts:12565-12581 一带的 critiqueBus)。只推前者的话,生产环境里挂载点一帧都收不到。
sseToPanelEvent(apps/web/src/components/Theater/state/sse.ts:91)的两手防御:
- 频道名是 type 的唯一权威——先 spread
data再最后钉type(sse.ts:68)。反过来的话,一个畸形帧自带的type就能把critique.run_started频道的内容路由成 ship 动作。 - 出门前必须过
isPanelEvent,那是契约层的严格守卫:校验头部字段、每个变体的必填项、所有闭合枚举成员、拒绝非有限数。少runId、status 不认识、composite 是 NaN 的帧在这里就被丢掉,reducer 永远见不到。
文件注释还记了一笔"减法":早先在 isPanelEvent 之上还叠了一层 hasValidVariantShape 影子守卫,后来发现它严格弱于契约守卫、永远不可能拒绝契约放行的帧,于是删掉,只保留回归测试防止两层里任何一层被削弱。
连接管理(createCritiqueEventsConnection,sse.ts:85)是指数退避 1s → 30s 封顶,收到一次 ready 就把退避重置(sse.ts:124-126)——网络抖一下不该永久拉大事件间隔。单个畸形 payload 只在 dev 下打个 warn,不撕连接(sse.ts:109-117)。
useCritiqueStream(apps/web/src/components/Theater/hooks/useCritiqueStream.ts:45)的第一条语句是 dispatch({ type: '__reset__' })(useCritiqueStream.ts:69),在判断要不要建连接之前。理由写在注释里:从项目 A 切到 B 时,不重置的话 B 的 run_started 到达前会一直渲染 A 的评审;enabled 从 true 翻 false 时,连接拆了但界面上那次 run 还挂着。__reset__ 是 action union 的一员而非独立 setter(state/reducer.ts:21-24),reducer 因此保持"状态转移的唯一真源"。
中断按钮有两处被真实 bug 逼出来的克制:
InterruptButton(apps/web/src/components/Theater/InterruptButton.tsx)绑的是 window 级 Escape。它会先看事件目标:在 input/textarea/select/contenteditable 里按 Esc 一律不算(InterruptButton.tsx:33-45);页面上开着[role="dialog"]或aria-modal="true"时也让位给那个面板自己的 Esc 处理——除非事件本来就来自.theater-stage内部。CritiqueTheaterMount(apps/web/src/components/Theater/CritiqueTheaterMount.tsx:63)等 daemon 的 ack 才切状态:只有 HTTP 2xx 才 dispatchinterrupted(CritiqueTheaterMount.tsx:198);404/409 或网络失败则清掉 pending 让用户重试,真实的 SSE 终态事件仍然赢。老写法是发请求的同时就本地切 interrupted,结果端点没接线(404)时 UI 也会进入粘滞的中断态,把 daemon 后来发的所有真终态全忽略掉。- 点击时快照当时的轮次和分数(
CritiqueTheaterMount.tsx:183),因为 fetch 是异步的、期间 SSE 可能已经推进。快照由bestRoundAndComposite(CritiqueTheaterMount.tsx:256)单趟算出,成对返回。分成两个函数时曾经出过 bug:一个取"最后关闭的轮次"、一个取"最高分",在非单调的 run 上(第 1 轮 8.5、第 2 轮 6.0)会报出round 2 / composite 8.5这种从未存在过的组合。
ScoreTicker(ScoreTicker.tsx)在第一轮关闭前只显示阈值标签、值显示 —,不画 0 分基线——避免让用户以为"当前分是 0"。TheaterDegraded(TheaterDegraded.tsx)把降级原因映射到 i18n 文案并渲染成一枚说明性 chip,而不是留一个"评分中"的假进度。
5. 巧妙之处(可借鉴的技术)
- 让裁判和选手是同一个进程,但让记分员不是。 评审人格是 prompt 变出来的(省一个进程、省一份上下文),但分数由 daemon 重算(
orchestrator.ts:329/scoreboard.ts:27)。这条分界线让"LLM as judge"从一个说服游戏变成一个可审计的数据管道:模型的自我声明降级为一条可观测的告警(composite_mismatch),而不是判决。 - 必改项一票否决。
decideRound(scoreboard.ts:51)要求分数过线且 must-fix 为 0。只看加权分的话,四个高分能把一条"对比度不达标"淹掉。 - 大 payload 走侧信道。 产物字节经
onArtifact回调直达编排器(v1.ts:440),SSE 上只跑一个引用。任何"事件类型同时就是广播线上形状"的系统都会撞上这个问题。 - 写盘、钉行、再广播的固定顺序。
orchestrator.ts:488-545的三步是被一次真实竞态逼出来的:客户端听到 ship 就去取产物,而行上的路径还没写。 - CDATA 感知的扫描。
indexOfOutsideCdata/extractArtifactBlock(v1.ts:651/v1.ts:651)解决的是"协议标签和被传输内容同形"这个通用难题——blockEnd偏移把兄弟标签的搜索范围限死,是这类协议的标准解法。 - 不认识就当没设置。
narrowProjectCritiqueOverride只认真 boolean(spawn-inputs.ts:34)、parseEnvEnabled未设置返回null而非false(rollout.ts:135)、parseRolloutPhase未知值退回 M0(rollout.ts:116)。三处的共同性质:损坏或误配的输入永远只能让功能更保守。 - 退回门槛低于推进门槛。
demoteFloor = shippedThreshold / 2(ratchet.ts:205)。灰度系统最容易犯的错是推进和回退用同一条线,结果在阈值附近来回抖。 - 写端 不去重、读端取最后一条。
conformance-history.ts的追加写 + 读时保留末条,用 O(1) 的写换掉一个随历史增长的读改写循环。 - 静态 lint 的输出直接就是下一轮 prompt。
renderFindingsForAgent(lint-artifact.ts:519)不是给人看的报告,是给模型看的指令:带统计、带 fix、带片段、明令不许写解释。这是"检查器 → 生成器"回路里最省事的一种接法。 - UI 等服务端 ack 才改状态。
CritiqueTheaterMount.tsx:198的乐观更新回退,是所有"取消/删除"类按钮都该抄的形状。
6. 边界与局限
诚实清单:
- 不是真正的第二个 agent。 评审跑在同一个 CLI、同一个上下文里,所以它继承主生成的全部盲区。真正的独立复核目前只有不花 token 的
lintArtifact。 - 只支持 plain-stream 适配器。 25 家 CLI 里只有 5 家(aider / deepseek / qwen / antigravity / grok-build)能享受这套;其余结构化包装流的 CLI 直接跳过评审、回落传统单遍生成(
server.ts:7417-7423),按格式解码进编排器被明确标为 v2 议题。 - 降级注册表活不过重启。
adapter-degraded.ts是进程内 Map,文件头承认持久化要等一次 schema 迁移。daemon 重启即失忆。 - 合规 harness 不含重试预算。 「每模板一次重试、连续两次降级才算一次失败、≥90% shipped + ≥95% clean-parse」这些策略明确不在
conformance.ts里,留给调用它的调度器(conformance.ts:16-21)。仓库里那个调度器本身不在本章覆盖范围。 - 棘轮只出建议,不动开关。
evaluateRollout是纯函数,GET /api/critique/conformance只返回 decision;真正翻OD_CRITIQUE_ROLLOUT_PHASE需要人。 - 灰度解析器与实际 spawn 门之间曾经脱节。
rollout.ts:32-36的注释直说:想今天打开的运维应该设OD_CRITIQUE_ENABLED=1,因为 spawn 时的闸还没重新指向这个解析器。当前server.ts:5111已经在调isCritiqueEnabled,那段注释是文档滞后于代码的痕迹。 - lint 是正则不是解析器。 明说容忍误报(
lint-artifact.ts:11-13)。规则表也是一份审美意见——反紫色渐变、反 emoji 图标、要求衬线 display face——换个品牌语境可能全不适用。 - finalize 的锁没有陈旧恢复。 崩溃留下的
.finalize.lock要人工rm(finalize-design.ts:9-12)。 decideRound的 must-fix 计数来自事件而非语义。 编排器按panelist_must_fix事件数累加(orchestrator.ts:334-337),一个把同一个问题拆成三条说的评审角色,会比一条说完的角色造成更重的否决。- P0/P1/P2 的分级不参与 ship 判定。 lint findings 走的是
/api/artifacts/*那条产物保存路径,和评审的复合分/棘轮是两套互不通电的系统。
7. 横向对比
同组其它章的分工:
| 章 | 与本章的关系 |
|---|---|
| 主线:一次 run 从按下回车到产物落盘 | 本章的编排器是主 run 生命周期里的一个分支;主 run 自己的失败分类与重试在那一章 |
| 适配 25 家 CLI | streamFormat 与 RuntimeAgentDef 决定哪 5 家适配器有资格进评审 |
| 提示词工厂 | 评审人格那段附录(renderPanelPrompt)怎么拼进系统提示词、钉在第几段 |
| 文件系统即产品 | od.critique.policy 所在的 SKILL.md frontmatter、设计系统 DESIGN.md 从哪来 |
| 产物与沙箱预览 | 本章写盘的产物在前端怎么被渲染成能点的页面 |
跨库看,这套东西的取舍位置是:评审是产品面板而不是 CI 步骤——评分实时流到用户眼前、可被 Esc 打断、跑在同一次生成里。代价是它必须与主 run 共享上下文、必须容忍模型撒谎(于是有了 daemon 重算)、必须对 25 种 CLI 的输出格式做筛选。相比之下,把评审做成独立进程/独立 CI 步骤的方案能换到独立性和更强的模型,但拿不到"用户看着分数往上爬"这个体验。