跳到主要内容

数据截至 (上游 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:12524if (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/failedapps/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.mdapps/daemon/src/finalize-design.ts
前端剧场SSE 订阅 + reducer + 五条泳道 UIapps/web/src/components/Theater/

主线走一遍(高层):

  1. spawn 前先问闸门:这次 run 要不要评审(isCritiqueEnabled)。要的话,prompt 里追加评审人格,critiqueShouldRun = true
  2. CLI 启动,stdout 同时被评审编排器消费。模型按线协议吐 <CRITIQUE_RUN> → 若干 <ROUND> + 五个 <PANELIST><ROUND_END> → 达标就 <SHIP>
  3. 编排器边解析边算分。模型自己写在标签属性里的 composite 只是建议,daemon 用自己的权重重算,两者差太多就报一条 composite_mismatch 警告。
  4. 终态落库:产物写盘、transcript 落 ndjson、SQLite 行从 running 改成终态、SSE 推给前端。
  5. 离线的两条旁路:lintArtifact 在保存产物时体检并把发现回喂;跨天的合规统计喂给棘轮决定灰度阶段。

3. 核心机制

3.1 评审编排:一次 run 怎么起、怎么死

它要解决的小问题: 一个可能跑几分钟、随时会卡住、随时会被用户掐掉的流式过程,怎么保证永远走到一个明确的终态并落库

思路。 编排器把自己写成一个"总是有结局"的状态机:开头先插一行 status='running',中间无论出什么事,最后都必须 updateCritiqueRun 写进六种终态之一。

终态什么时候有没有兜底 ship
shipped有 SHIP 且 daemon 重算后达标
below_threshold有 SHIP 但重算不达标,或压根没 SHIP 走兜底有(selectFallbackRound
timed_out单轮或总时长超时有(已完成的最好一轮)
interrupted用户 abort,或子进程被信号杀掉
degraded解析器认定协议坏了
failed子进程非零退出,或编排器内部异常

真实入口是 runOrchestratorapps/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); // 谁先到听谁的

真实实现是 applyTimeoutsorchestrator.ts:895),赛跑数组组装在 orchestrator.ts:922-945。两个细节值得抄:

  • 单轮计时器只在轮内存在。 roundDeadline 在收到某轮第一个 panelist_open 时设,round_end 时置回 nullorchestrator.ts:314orchestrator.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|ridapps/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

对应的落库函数 markRunInterruptedRecoveryapps/daemon/src/critique/persistence.ts:327)在 SQL 里带 AND status = 'running' 守卫,所以刚刚自己转成别的终态的行不会被覆盖。

适配器降级:把不会说协议的 CLI 关在门外

评审依赖模型能吐出结构化标签,但 25 家 CLI 不是每家都做得到。这里有两级过滤:

  1. 格式级,硬性。 只有 streamFormat === 'plain' 的适配器有资格——25 家里正好 5 家(aider / deepseek / qwen / antigravity / grok-build,完整分布表见 适配 25 家 CLI §3.4)。这道闸拧了两遍:critiqueShouldRun 本身就与上了 isPlainAdapterapps/daemon/src/server.ts:9419-9424),spawn 分支进门再查一次 def.streamFormat,非 plain 就跳过编排器、回落传统单遍生成,并对每种格式打一次性警告(server.ts:7417-7423)。理由是解析器只认裸 stdout,喂它包装字节等于自找 MalformedBlockError
  2. 健康级,带 TTL。 apps/daemon/src/critique/adapter-degraded.ts 是一张进程内的"病号表":markDegraded(adapterId, reason, ttlMs) 打标(adapter-degraded.ts:49),默认 24 小时(ADAPTER_DEGRADED_DEFAULT_TTL_MSadapter-degraded.ts:42)。读的时候顺手清过期项——getDegradedEntry 发现过期就 store.delete 再返回 null(adapter-degraded.ts:94-102),所以调用方不需要单独跑淘汰。

设计取舍写在文件头:v1 只做进程内 Map,持久化推到后续阶段,但把 markDegraded / isDegraded 的签名先定死,将来换存储层是原地替换。


3.2 解析与写回:从标签流到磁盘

它要解决的小问题: 模型输出是,标签会被切在任意位置;同时模型可能撒谎(自称的分对不上)、可能超量(吐一个 20MB 的 HTML)、可能在产物里嵌套跟协议同名的字符串。

事件流的形状

解析器 parseV1apps/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

主循环的核心是 drainv1.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 / isOutOfRangev1.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:216artifactBuffer),事件本身只带一个小小的 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 完后查)一个永远不闭合的失控块
写盘上限writeShipArtifactmaxBytesartifact-writer.ts:101落盘前最后一道

三处都用 Buffer.byteLength(x, 'utf8') 而非字符串 .length——一 buffer 的中日韩文字或 emoji 能在字符数没超的情况下远超字节上限。

写盘与读盘:都当环境不可信

writeShipArtifactapps/daemon/src/critique/artifact-writer.ts:90):mime 走一张窄白名单(ARTIFACT_MIME_EXTENSIONSartifact-writer.ts:17,只有 html/css/md/txt/json/svg),不认识的一律落成 .bin + application/octet-stream;写法是先写兄弟临时文件再 renameartifact-writer.ts:114-117),避免崩溃时留下半个 artifact.html

读端 handleCritiqueArtifactapps/daemon/src/critique/artifact-handler.ts:41)的防护更密:

  1. 跨项目泄露守卫,返 404 而非 403(artifact-handler.ts:82-87),不泄露别的项目有没有这个 run。
  2. 路径穿越守卫:行里的 artifactPath 必须 resolve 到配置的 artifacts 根之内(artifact-handler.ts:112-123),被篡改的 DB 行读不出任意文件。
  3. 只 open 一次,带 O_NOFOLLOW,然后对已打开的 fd 做 stat(artifact-handler.ts:139-161)。老写法是 lstat(path) 校验完再 createReadStream(path),中间留了一个 TOCTOU 窗口,本地有写权限的人能在两步之间换成符号链接。
  4. SVG / HTML 的响应额外挂 default-src 'none' 的 CSP(artifact-handler.ts:183-188),给绕过沙箱 iframe 直接访问 URL 的客户端兜底(沙箱预览那半套见 产物与沙箱预览)。

transcript 与落库

writeTranscriptapps/daemon/src/critique/transcript.ts:31)把事件序列以 ndjson 流式写盘,按累计 UTF-8 字节数(不是数组长度)决定要不要 gzip,阈值 256 KiB(transcript.ts:14transcript.ts:94)。gzip 路径先写 .gz.tmpfh.sync() fsync、再 rename(transcript.ts:100-115),因此崩溃永远不会留下一个零字节的合法命名 .gz。逆操作是 readTranscripttranscript.ts:149),replay 路径用它。

落库那层(apps/daemon/src/critique/persistence.ts)有两个不显然的点:

  • rounds_json 这一列存两种形状:没有恢复原因时存裸数组,有的时候存 { rounds, recoveryReason } 信封(persistence.ts:88-99),读取端 parseRoundsPayload 两种都吃、坏 JSON 返空数组(persistence.ts:101-121)。用一个已有列承载可选元数据,省掉一次 schema 迁移。
  • 启动时的 reconcileStaleRunspersistence.ts:356)在一个事务里把所有超期的 running 行改成 interrupted 并盖上 recoveryReason='daemon_restart'。server 在 boot 时调用它,staleAfterMs 直接取 critiqueCfg.totalTimeoutMsapps/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:329computeComposite),比对与告警在 orchestrator.ts:363-366,容差常量 COMPOSITE_TOLERANCE = 0.01orchestrator.ts:52)。

权重表来自 defaultCritiqueConfig()packages/contracts/src/critique.ts:58-72):

角色权重备注
designer0出活的人不给自己算分
critic0.4挑刺者占最大头
brand0.2
a11y0.2
copy0.2

默认及格线 8.0 / 分制 10 / 最多 3 轮。computeCompositeapps/daemon/src/critique/scoreboard.ts:27)在有角色缺席时把权重按现存角色等比重分,而不是当 0 分算——少一个评审不应该等于挨了一顿差评。

及格判定 decideRoundscoreboard.ts:51)是两个条件的与:复合分过线(带 1e-9 浮点容差) must-fix 数为 0。也就是说任何一条"必改"都能一票否决一个高分。

权威性还体现在拒收伪造的 SHIP。 如果模型给一个 daemon 从没关闭过的轮次发 SHIP,编排器直接丢弃它、发一条 duplicate_ship 警告、掉进无 SHIP 的兜底路径(orchestrator.ts:437-448)。

没有 SHIP 时的兜底由 selectFallbackRoundscoreboard.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 / MissingArtifactdegraded,原因即错误名
2源抛其它错failed / unexpected_error
3流里任何位置出现过 parser_warningdegraded / parser_warning
4有 SHIP,但发货那一轮没凑齐阵容degraded / incomplete_panel
5有 SHIP、阵容齐、零警告shipped
6流结束还没 SHIPfailed / no_ship

三个抠出来的细节:

  • 看到 SHIP 不能立刻返回。 解析器的 duplicate_ship 警告是在第一个 ship 事件 yield 之后才发的,所以 harness 必须把流排干再分类(conformance.ts:230-248 只记录第一个 ship,循环继续)。
  • 阵容按轮分桶。 closedRolesByRoundMap<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-314mark),本地的两个原因(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 中途被打断留下的是"可恢复"的文件,读端 readConformanceHistoryconformance-history.ts:78)对解析失败的行直接丢弃而不是抛。
  • 目录按适配器分 → "清掉适配器 X 的全部历史"是一次 rm -rf

写入不做去重(appendConformanceDayconformance-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(含数据不足 / 差一点 / 已到顶或底)

四条值得学的工程习惯:

  1. 入口先防御参数。 windowDays <= 0 会让 passingDays >= windowDays 在 0 >= 0 时恒真——一个零证据的推进。所以非法的 window / 阈值一律返回 hold 并说明原因(ratchet.ts:149-175)。
  2. 舰队聚合是加权平均。 每个适配器按自己的 totalRuns 加权进分子分母(ratchet.ts:193-197),而不是把各家的比率做简单平均——否则跑了 3 次的小适配器和跑了 300 次的主力同权。
  3. 退回门槛低于推进门槛。 demoteFloor = shippedThreshold / 2ratchet.ts:205)。目的写在文档注释里:单独一天掉到 0.88 不该把灰度弹回去,退回信号是留给持续性崩坏的。
  4. 缺数据 ≠ 失败。 某天完全没有行时 continueratchet.ts:207-208),最后归到 hold 的"insufficient data"分支。默认阈值 0.90 shipped / 0.95 clean-parse / 14 天,都可覆盖。

出口是 GET /api/critique/conformanceapps/daemon/src/routes/daemon.ts:184-199):读历史 → 喂棘轮 → 返回 { window, decision }它只给建议,不动开关;真正翻 OD_CRITIQUE_ROLLOUT_PHASE 是运维的事。

第四步:灰度开关本身

isCritiqueEnabledapps/daemon/src/critique/rollout.ts:84)是四级优先级,第一条命中即停:

优先级条件结果
1skill 声明 opt-outfalse(一票否决,env 也压不过)
1skill 声明 requiredtrue
2项目级覆盖非 null取项目值
3环境变量 OD_CRITIQUE_ENABLED 非 null取 env 值
4阶段 M0 / M1false
4阶段 M2只对 opt-in 的 skill 为 true
4阶段 M3true

三个输入各有一个专职的窄函数,都是"看不懂就当没设置":

  • skill 那一路normalizeCritiquePolicyapps/daemon/src/skills.ts:697):只认 trim + 小写后的 required / opt-in / opt-out,其余(包括拼错的)一律 null 落到下一级。它被 listSkills() 在解析 SKILL.md frontmatter 时调用(skills.ts:359skills.ts:404),资产扫描那套见 文件系统即产品
  • 项目那一路narrowProjectCritiqueOverrideapps/daemon/src/critique/spawn-inputs.ts:34)。整个函数只有四行,但被单独拎出来成文件是有理由的:项目 metadata 是自由 JSON blob,经 SQLite 往返后类型不可信。这里只认真正的 boolean,字符串 'true'、数字 1null、嵌套对象通通塌成 null。文件头把这条性质点名为"承重的安全属性:一次损坏的 metadata 写入绝不能意外把功能打开"。
  • env 那一路parseEnvEnabledrollout.ts:135),未设置返回 null 而不是 false——"没说"和"说了不要"必须是两件事,否则第 4 级的阶段默认永远不会生效。阶段解析 parseRolloutPhaserollout.ts:116)遇到未知值退回 M0,全新安装绝不会被意外打开。

调用点在 spawn 前(apps/daemon/src/server.ts:9383-9389),随后与另外四个条件求与得到 critiqueShouldRunserver.ts:5146-5151):闸门通过 有品牌 有 skill 不是媒体面板 是 plain 适配器。这个合成量同时决定"prompt 里加不加评审人格"和"走不走编排器",两边必须严格同步——否则要么模型被告知要吐标签却没人解析,要么解析器在等一堆模型压根没被要求发的标签。


3.4 不靠模型的静态体检

它要解决的小问题: 有些毛病不需要评审团、不值得花 token,而且模型自己评自己时经常看不见——因为那正是它的默认审美。

lintArtifactapps/daemon/src/lint-artifact.ts:120)是一堆正则检查,故意不解析 HTML。文件头把取舍写明:便宜、确定、易扩展,误报可接受,因为每条发现都带原文片段供 agent 自己复核。

检查项目录(severity / id / 抓什么):

级别id抓的东西
P0purple-gradient渐变里出现紫/靛系 hex 或 purple/violet 关键字
P0trust-gradient蓝→青两段式"信任渐变"(SaaS hero 陈词滥调)
P0ai-default-indigo单独用 Tailwind 靛蓝作强调色——"最被举报的 AI 痕迹"
P0emoji-icon✨🚀🎯 之类当功能图标用在标题/按钮/列表项里
P0left-accent-card圆角卡片 + 彩色左边框这一经典组合
P0sans-displayh1/h2/h3 的 display face 退回 Inter/Roboto/system-sans
P0invented-metric"10× faster"、"99.9% uptime" 这类编造指标
P0filler-copylorem ipsum / "Feature One" / placeholder text
P0scroll-into-viewElement.scrollIntoView()——跨 iframe 会把宿主页拽走
P0slide-theme-missingdeck 形态里 .slide 缺 light/dark 主题类
P1all-caps-no-trackingtext-transform: uppercase 没配 ≥0.06em 字距
P1external-imageunsplash / placehold.co 等外部占位图 CDN
P1raw-hex:root 之外裸 hex 超过 12 个(token 没被遵守)
P1accent-overusebody 里 var(--accent) 内联用超过 6 次
P1slide-rhythm连续三张同主题幻灯片
P2missing-section-anchor<section>data-od-id / data-screen-label

这份表最有意思的不是规则本身,而是为了不误伤而堆的那层"CSS 半解释器"。以 all-caps-no-tracking 为例:

  • 先剥掉 HTML 注释和 CSS 注释(lint-artifact.ts:127lint-artifact.ts:327)——被注释掉的示例代码不该报错。
  • 规则体的正则用 [^{}]* 而不是 [^}]*lint-artifact.ts:342),这样只匹配最内层规则,@media 包裹不会被当成一条选择器吞掉。
  • 字距合规判断 hasAdequateUppercaseTrackinglint-artifact.ts:637)会把 var(--x) 按 token 表解析成字面值再判;单位 em 直接和 0.06 比,rem/px 换算成绝对 px 后与同规则内的 font-size × 0.06 比;同规则里声明了无法解析单位的 font-size拒绝走宽松兜底lint-artifact.ts:670),因为标题可能任意大。
  • token 表按主题分桶(buildResolvedThemeslint-artifact.ts:695)。老实现把所有 token 按名字合并再做笛卡尔积,会造出 (默认主题的字号, 暗色主题的字距) 这种现实中不存在的组合,在正常的明暗双主题产物上误报。现在同一 scope 内声明的值始终配对评估,且每个主题都得达标才算通过。
  • ai-default-indigo 的逃生舱只留给 --accentstripTokenBlockslint-artifact.ts:893)会把纯 token 形状的全局主题块整块摘掉再扫,但 declarationLaundersIndigolint-artifact.ts:935)会把 :root { --primary: #6366f1 } 这种"换个名字洗白靛蓝"的块留在扫描范围里。

回喂才是闭环的那一笔。 renderFindingsForAgentlint-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 + agentMessageindex.ts:1902-1905),聊天层用它在写盘前就把问题捅回去。


3.5 收口成资产:把一场对话压成一份 DESIGN.md

它要解决的小问题: 评审过了、产物有了,但知识还散在几十轮聊天里。新来的人(或下一个 LLM)不该靠回放整段对话来重建上下文。

finalizeDesignPackageapps/daemon/src/design/finalize-design.ts:263)是一次性的合成:transcript + 设计系统 + 当前产物 → 直连 provider → 写出 <projectDir>/DESIGN.md

四个输入怎么凑齐:

transcript (SQLite 导出 .jsonl) ──truncateTranscriptForPrompt──┐
设计系统 DESIGN.md 正文 ────────────────────────────────────────┤
当前产物(活动 tab → 最新 sidecar → null)──resolveCurrentArtifact─┤──▶ buildSynthesisPrompt
生成上下文(时间/项目 id/消息数)───────────────────────────────┘
  • resolveCurrentArtifactfinalize-design.ts:158)的三级回退:① 活动 tab 且磁盘上真有 .artifact.json 边车;② 所有带边车的文件里按 manifest.updatedAt 降序取最新(无 updatedAt 的排最后);③ null。tab 名在拼进路径之前先过 validateProjectPathfinalize-design.ts:182),拦住 ../../../etc/passwd 之类;校验失败不是中止 finalize,而是退到第二级。
  • truncateTranscriptForPromptfinalize-design.ts:936)保头保尾丢中间:超过 384 KiB 就保留 header 行、按对半的字节预算各留一段头和尾,中间插一行哨兵 {"kind":"truncated","reason":"size","omittedBytes":N}omittedBytes 是真实字节差,下游能看出缺了多少。磁盘上的 transcript 不动,截断只活在 prompt 里。
  • buildSynthesisPromptfinalize-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.lockEEXIST 就抛 FinalizePackageLockedErrorfinalize-design.ts:299-309);写文件走 writeFileSync({flag:'wx'}) → 重开 fsync → rename(finalize-design.ts:420-437),失败则 unlink 临时文件。锁在 finally 里释放。

直连 provider 的重试姿态是刻意保守的。 callFinalizeProviderWithRetryfinalize-design.ts:660)只在 429 或 5xx 上重试一次,退避固定 1 秒;终态失败抛 FinalizeUpstreamError,带上游 HTTP 状态和原始 body 供路由层做 AUTH_FAILED / RATE_LIMITED / UPSTREAM_FAILED 的映射与脱敏。注释直说:这是"意见",理由是 daemon 其它同步端点一次都不重试,/finalize 是阻塞式的,不该让用户等两轮退避。callAnthropicWithRetryfinalize-design.ts:698)现在只是钉了 protocol 的薄壳,老调用点不用改。

超时是双保险:总是建一个 120s 的 AbortController,调用方另给的 signal 用 AbortSignal.any 合并而不是替换(finalize-design.ts:355-370)。曾经的写法是"有调用方 signal 就用它",结果把超时给关掉了。

响应解析按协议分叉。 extractDesignMdfinalize-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

前端两块。 buildFinalizeRequestapps/web/src/lib/resolve-finalize-request.ts:45)从 AppConfig 里挑出该协议的 BYOK 字段,缺 key 或缺 model 就返回 null;配套的 buildFinalizeCredentialsMissingToastresolve-finalize-request.ts:65)在 daemon 模式下会明说"本地 CLI 登录只管聊天,finalize 需要 BYOK"——把一个容易困惑的边界翻译成人话。

useFinalizeProjectapps/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)。只推前者的话,生产环境里挂载点一帧都收不到。

sseToPanelEventapps/web/src/components/Theater/state/sse.ts:91)的两手防御:

  1. 频道名是 type 的唯一权威——先 spread data 再最后钉 typesse.ts:68)。反过来的话,一个畸形帧自带的 type 就能把 critique.run_started 频道的内容路由成 ship 动作。
  2. 出门前必须过 isPanelEvent,那是契约层的严格守卫:校验头部字段、每个变体的必填项、所有闭合枚举成员、拒绝非有限数。少 runId、status 不认识、composite 是 NaN 的帧在这里就被丢掉,reducer 永远见不到。

文件注释还记了一笔"减法":早先在 isPanelEvent 之上还叠了一层 hasValidVariantShape 影子守卫,后来发现它严格弱于契约守卫、永远不可能拒绝契约放行的帧,于是删掉,只保留回归测试防止两层里任何一层被削弱。

连接管理createCritiqueEventsConnectionsse.ts:85)是指数退避 1s → 30s 封顶,收到一次 ready 就把退避重置(sse.ts:124-126)——网络抖一下不该永久拉大事件间隔。单个畸形 payload 只在 dev 下打个 warn,不撕连接(sse.ts:109-117)。

useCritiqueStreamapps/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 逼出来的克制:

  • InterruptButtonapps/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 内部。
  • CritiqueTheaterMountapps/web/src/components/Theater/CritiqueTheaterMount.tsx:63等 daemon 的 ack 才切状态:只有 HTTP 2xx 才 dispatch interruptedCritiqueTheaterMount.tsx:198);404/409 或网络失败则清掉 pending 让用户重试,真实的 SSE 终态事件仍然赢。老写法是发请求的同时就本地切 interrupted,结果端点没接线(404)时 UI 也会进入粘滞的中断态,把 daemon 后来发的所有真终态全忽略掉。
  • 点击时快照当时的轮次和分数CritiqueTheaterMount.tsx:183),因为 fetch 是异步的、期间 SSE 可能已经推进。快照由 bestRoundAndCompositeCritiqueTheaterMount.tsx:256)单趟算出,成对返回。分成两个函数时曾经出过 bug:一个取"最后关闭的轮次"、一个取"最高分",在非单调的 run 上(第 1 轮 8.5、第 2 轮 6.0)会报出 round 2 / composite 8.5 这种从未存在过的组合。

ScoreTickerScoreTicker.tsx)在第一轮关闭前只显示阈值标签、值显示 ,不画 0 分基线——避免让用户以为"当前分是 0"。TheaterDegradedTheaterDegraded.tsx)把降级原因映射到 i18n 文案并渲染成一枚说明性 chip,而不是留一个"评分中"的假进度。


5. 巧妙之处(可借鉴的技术)

  1. 让裁判和选手是同一个进程,但让记分员不是。 评审人格是 prompt 变出来的(省一个进程、省一份上下文),但分数由 daemon 重算orchestrator.ts:329 / scoreboard.ts:27)。这条分界线让"LLM as judge"从一个说服游戏变成一个可审计的数据管道:模型的自我声明降级为一条可观测的告警(composite_mismatch),而不是判决。
  2. 必改项一票否决。 decideRoundscoreboard.ts:51)要求分数过线 must-fix 为 0。只看加权分的话,四个高分能把一条"对比度不达标"淹掉。
  3. 大 payload 走侧信道。 产物字节经 onArtifact 回调直达编排器(v1.ts:440),SSE 上只跑一个引用。任何"事件类型同时就是广播线上形状"的系统都会撞上这个问题。
  4. 写盘、钉行、再广播的固定顺序。 orchestrator.ts:488-545 的三步是被一次真实竞态逼出来的:客户端听到 ship 就去取产物,而行上的路径还没写。
  5. CDATA 感知的扫描。 indexOfOutsideCdata / extractArtifactBlockv1.ts:651 / v1.ts:651)解决的是"协议标签和被传输内容同形"这个通用难题——blockEnd 偏移把兄弟标签的搜索范围限死,是这类协议的标准解法。
  6. 不认识就当没设置。 narrowProjectCritiqueOverride 只认真 boolean(spawn-inputs.ts:34)、parseEnvEnabled 未设置返回 null 而非 falserollout.ts:135)、parseRolloutPhase 未知值退回 M0(rollout.ts:116)。三处的共同性质:损坏或误配的输入永远只能让功能更保守
  7. 退回门槛低于推进门槛。 demoteFloor = shippedThreshold / 2ratchet.ts:205)。灰度系统最容易犯的错是推进和回退用同一条线,结果在阈值附近来回抖。
  8. 写端不去重、读端取最后一条。 conformance-history.ts 的追加写 + 读时保留末条,用 O(1) 的写换掉一个随历史增长的读改写循环。
  9. 静态 lint 的输出直接就是下一轮 prompt。 renderFindingsForAgentlint-artifact.ts:519)不是给人看的报告,是给模型看的指令:带统计、带 fix、带片段、明令不许写解释。这是"检查器 → 生成器"回路里最省事的一种接法。
  10. 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 要人工 rmfinalize-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 家 CLIstreamFormatRuntimeAgentDef 决定哪 5 家适配器有资格进评审
提示词工厂评审人格那段附录(renderPanelPrompt)怎么拼进系统提示词、钉在第几段
文件系统即产品od.critique.policy 所在的 SKILL.md frontmatter、设计系统 DESIGN.md 从哪来
产物与沙箱预览本章写盘的产物在前端怎么被渲染成能点的页面

跨库看,这套东西的取舍位置是:评审是产品面板而不是 CI 步骤——评分实时流到用户眼前、可被 Esc 打断、跑在同一次生成里。代价是它必须与主 run 共享上下文、必须容忍模型撒谎(于是有了 daemon 重算)、必须对 25 种 CLI 的输出格式做筛选。相比之下,把评审做成独立进程/独立 CI 步骤的方案能换到独立性和更强的模型,但拿不到"用户看着分数往上爬"这个体验。


8. 代码地图(导航索引)

主题文件路径符号名
评审 run 主循环apps/daemon/src/critique/orchestrator.tsrunOrchestrator
超时/中断/子进程退出的四方赛跑apps/daemon/src/critique/orchestrator.tsapplyTimeoutsmakeTimeoutRaceChildExitErrorChildSignaledError
daemon 权威记分apps/daemon/src/critique/scoreboard.tscomputeCompositedecideRoundselectFallbackRound
线协议解析apps/daemon/src/critique/parsers/v1.tsparseV1drainemitInner
CDATA 感知扫描apps/daemon/src/critique/parsers/v1.tsindexOfOutsideCdataextractArtifactBlock
产物侧信道apps/daemon/src/critique/parser.tsShipArtifactPayloadParserOptions.onArtifact
原子写产物apps/daemon/src/critique/artifact-writer.tswriteShipArtifactARTIFACT_MIME_EXTENSIONScanonicalizeMime
产物读端点与防护apps/daemon/src/critique/artifact-handler.tshandleCritiqueArtifact
transcript 落盘/回放apps/daemon/src/critique/transcript.tswriteTranscriptreadTranscript
落库与重启收尸apps/daemon/src/critique/persistence.tsmigrateCritiqueupdateCritiqueRunmarkRunInterruptedRecoveryreconcileStaleRuns
进程内 run 注册表apps/daemon/src/critique/run-registry.tscreateRunRegistrycompositeKey
中断 HTTP 端点apps/daemon/src/critique/interrupt-handler.tshandleCritiqueInterrupt
适配器病号表apps/daemon/src/critique/adapter-degraded.tsmarkDegradedgetDegradedEntrylistDegraded
协议合规分类apps/daemon/src/critique/conformance.tsrunAdapterConformancetoContractReason
合规历史存档apps/daemon/src/critique/conformance-history.tsappendConformanceDayreadConformanceHistory
灰度棘轮apps/daemon/src/critique/ratchet.tsevaluateRolloutConformanceDayRatchetDecision
灰度四级解析apps/daemon/src/critique/rollout.tsisCritiqueEnabledparseRolloutPhaseparseEnvEnabled
项目覆盖收窄apps/daemon/src/critique/spawn-inputs.tsnarrowProjectCritiqueOverride
skill 策略归一apps/daemon/src/skills.tsnormalizeCritiquePolicy
环境变量装配apps/daemon/src/critique/config.tsloadCritiqueConfigFromEnv
默认权重与阈值packages/contracts/src/critique.tsdefaultCritiqueConfigPANELIST_ROLESCRITIQUE_SSE_EVENT_NAMES
评审人格附录(拼装细节见 03 章)apps/daemon/src/prompts/panel.tsrenderPanelPrompt
反 AI 味儿 lintapps/daemon/src/lint-artifact.tslintArtifactrenderFindingsForAgent
lint 的 CSS 半解释器apps/daemon/src/lint-artifact.tshasAdequateUppercaseTrackingbuildResolvedThemesstripTokenBlocks
lint 的两个 HTTP 出口apps/daemon/src/routes/project/index.tsregisterProjectArtifactRoutes
DESIGN.md 合成apps/daemon/src/finalize-design.tsfinalizeDesignPackageresolveCurrentArtifactbuildSynthesisPrompttruncateTranscriptForPrompt
直连 provider 重试apps/daemon/src/finalize-design.tscallFinalizeProviderWithRetrycallAnthropicWithRetryextractDesignMd
finalize 前端请求装配apps/web/src/lib/resolve-finalize-request.tsbuildFinalizeRequestisFinalizeByokConfigured
finalize 前端生命周期apps/web/src/hooks/useFinalizeProject.tsuseFinalizeProjectmessageForCode
SSE → reducer actionapps/web/src/components/Theater/state/sse.tssseToPanelEventcreateCritiqueEventsConnection
剧场订阅 hookapps/web/src/components/Theater/hooks/useCritiqueStream.tsuseCritiqueStream
剧场挂载与中断握手apps/web/src/components/Theater/CritiqueTheaterMount.tsxCritiqueTheaterMountbestRoundAndComposite
剧场阶段路由apps/web/src/components/Theater/TheaterStage.tsxTheaterStage
spawn 时的评审总闸apps/daemon/src/server.tscritiqueShouldRunserver.ts:5146)、评审分支(server.ts:7416
棘轮 HTTP 出口apps/daemon/src/routes/daemon.tsGET /api/critique/conformancedaemon.ts:155