基于 FigmaMCP 的 D2C 工具(八): 账本与 evolve——只提案,不自动改库

20 分钟阅读

在前七篇中,我们已经完成:

  • 同一份设计稿为什么会生成出漂移的结果
  • 为什么我把归一化做在工具侧,而不是让设计师再出一份干净稿
  • catalog 三层:generated / overrides / links,合并成只读的 CatalogSnapshot
  • ingest 产出 DesignIntentIR
  • decide 产出 DecisionRecord、verdict 和 BindingPlan
  • verify 上:从源码抽 ComponentUsageIR,静态 ProofObligation 逐条回检
  • verify 下:skeleton 投影与运行时量测,visual-diff 只做诊断

本篇在此基础上讲这些 DecisionRecord 和 VerifyReport 落到哪里,以及怎么从里边把规则捞回来。主要是 decision ledger 怎么按月追加、FingerprintSet 怎么让老记录失效、evolve 怎么把已评审的记录聚合成 EvolutionProposal、提案被接受之后人怎么落到 overrides。中间会讲一次我踩过的坑:早期 evolve 是直接写 overrides 的,出过一次把人工判断覆盖掉的事,之后我把它改成只出提案。

我的目的是把账本和演进的流程讲清楚 不是把每个字段都列全 所以下面贴的类型和 JSON 都按真实实现精简过 字段名是真的 数量不全 组件名用 Input / Button / Table 代替真实业务组件

1. 验证绿了,规则不会自己变

先说一个我遇到的场景。一个 Input,设计稿要求禁止拉伸。Agent 每次生成都想传一个 resize prop,目标组件的 API 也确实支持这个 prop。但 catalog 里这个 Input 的 ExtensionRecipe 没有声明它,decide 落 needs-review。同事看一眼说这个可以过,手动改完合进去。下一次换个页面,Agent 又传 resize,decide 又落 needs-review,又是同一个人看一眼说可以过。

我翻了一下那段时间的输出目录,同一个组件附近有十几条 decision,字段几乎一样,verdict 在 needs-review 和 extend 之间来回。没有人把"可以过"写进 overrides。评审结论留在了聊天记录里,catalog 里什么都没变。

这就是我做账本和 evolve 要解决的事。裁决和验证的结果要有一个地方存下来,存下来的东西要能被机器读回去,读回去之后能变成给人审的改动建议。但改 catalog 的手必须是人的。

要点:

  • 账本记的是事实:什么时间、哪组指纹下、机器给过什么 verdict、人怎么评的
  • evolve 从事实里挖模式,产出提案
  • 提案被人接受后,人改 overrides,再 catalog build 出新的 snapshot
  • 三步里只有第一步是机器自动写文件,而且只追加

2. decision ledger:按月追加

2.1 目录怎么组织

落盘分两层。我们先来看目录:

text
tools/d2c/
├── catalog/
│   ├── generated/          # 编译导出的契约 机器生成
│   ├── overrides/          # 人工覆盖 一个组件一个 yaml
│   ├── links/              # Figma 链接
│   └── snapshot.json       # 三层合并后的只读 CatalogSnapshot
└── decision-log/
    └── v2/
        ├── 2026-07.jsonl   # 按月一个文件 只追加
        ├── 2026-08.jsonl
        └── 2026-09.jsonl

.d2c/                       # gitignore 工作区草稿
├── decisions/              # decide 的输出 一条一个 json
└── verify/                 # verify 的输出 VerifyReport

decision-log/v2/*.jsonl 是版本化的账本,进 git。.d2c/ 是当天调试用的草稿,不进 git。两边格式一样,都是 DecisionRecord,区别只在谁有资格被 evolve 默认读到。

约束:

  • 账本一行一条记录,jsonl。不做数据库,不做单个大 JSON 数组
  • 按 createdAt 的年月分文件,一年十二个
  • 只有 target 落到具体组件的记录才值得 log。page 级别 scope 的记录是整页扫一遍的副产物,只在草稿里活着,我不让它进账本

为什么是 jsonl 按月。我要的是 grep 和 code review 都还能做人。一年十二个文本文件,PR 里的 diff 只能是某个文件末尾多了几行。如果哪天出现一个改上个月文件中间某行的 diff,评审直接打回,不用看内容。这条规矩比任何校验代码都好用。

2.2 一条记录长什么样

账本里一行展开是这样。这是第 1 节那个 Input 后来补完 recipe 的一条 extend 记录,已经评审过:

json
{
  "schemaVersion": 2,
  "kind": "decision",
  "id": "decision:settings-form:12:401",
  "taskId": "settings-form",
  "designIntentId": "design:settings-form:12:400",
  "designNodeId": "12:401",
  "fingerprints": {
    "extractor": "557adf3c...20c6d0",
    "catalog": "f00a5404...23657e",
    "design": "ef855063...7f9ad",
    "source": "9f2c11ab...0e1a0"
  },
  "candidateUniverse": {
    "scope": "app/pages/settings/index.vue",
    "status": "complete",
    "fingerprint": "f00a5404...23657e",
    "evidence": [
      { "source": "catalog-builder", "locator": "tools/d2c/catalog", "extractorVersion": "2.0.0", "fingerprint": "78a1fef5...792f3c", "confidence": 1 }
    ]
  },
  "candidates": [
    {
      "componentId": "input",
      "retrieval": { "rank": 0, "sources": ["identity-link"] },
      "outcome": "satisfiable",
      "constraints": [
        { "id": "boundary:hazards", "category": "boundary", "phase": "static", "outcome": "pass" },
        { "id": "contract:prop:resize", "category": "contract", "phase": "static", "outcome": "pass" }
      ],
      "unsatisfiedCore": [],
      "bindingPlan": {
        "schemaVersion": 2,
        "kind": "binding-plan",
        "componentId": "input",
        "operations": [
          {
            "kind": "set-prop",
            "prop": "resize",
            "value": { "kind": "literal", "value": "none" },
            "extensionRecipeId": "resize-control",
            "source": "provided"
          }
        ],
        "unresolved": []
      }
    },
    {
      "componentId": "textarea",
      "retrieval": { "rank": 1, "sources": ["structure-index"] },
      "outcome": "unsatisfiable",
      "unsatisfiedCore": ["contract:slot:prefix"],
      "compact": true
    }
  ],
  "verdict": "extend",
  "target": "input",
  "bindingPlan": { "componentId": "input", "operations": ["同上 略"] },
  "unresolved": [],
  "review": {
    "status": "approved",
    "reviewer": "skelanimals",
    "reviewedAt": "2026-09-12T03:21:07.000Z"
  },
  "createdAt": "2026-09-12T02:58:44.000Z"
}

几个字段多说两句:

  • fingerprints:四条指纹,第 3 节讲。这条记录的结论只在这四条指纹下成立
  • candidates:被选中的候选是完整形态,带 constraints 和 bindingPlan;落选的候选是 compact: true 的紧凑形态,只留 unsatisfiedCore。落选的完整约束可以用同一组指纹重放出来,没必要存
  • target:verdict 是 reuse / extend 时落到的组件 id。evolve 按它分组
  • review:人的盖章。没有这个字段,或者 status 不是 approved,evolve 不看

2.3 log-decision

写账本只有一个入口:

bash
d2c log-decision --input .d2c/decisions/settings-form.12:401.json

输出:

json
{
  "schemaVersion": 2,
  "kind": "decision-log-result",
  "decisionId": "decision:settings-form:12:401",
  "path": "tools/d2c/decision-log/v2/2026-09.jsonl"
}

实现很短,思路在注释里:

typescript
logDecision(input: unknown) {
  // 先过 schema 不合法的记录进不了账本
  const decision = DecisionRecordSchema.parse(input)
  const directory = join(this.projectRoot, 'tools/d2c/decision-log/v2')
  mkdirSync(directory, { recursive: true })
  // 按 createdAt 的年月定文件 不按当前时间 补录旧记录也能落到正确的月份
  const month = decision.createdAt.slice(0, 7)
  const file = join(directory, `${month}.jsonl`)
  // appendFileSync 只追加 这里没有任何读旧内容再写回的逻辑
  appendFileSync(file, `${JSON.stringify(decision)}\n`)
  return { schemaVersion: 2, kind: 'decision-log-result', decisionId: decision.id, path: file }
}

MCP 侧有同名工具 d2c_log_decision,走同一个 service 方法。Agent 跑完 decide 之后可以自己 log,但 log 进去的记录没有 review,对 evolve 来说是零。

2.4 只追加,review 是唯一例外

只追加的好处是审计简单:谁在什么时候基于哪组指纹给过什么 verdict,事后能回放。坏处是纠错不能靠改旧账。有同事问过,评审结论后来变了怎么办。我的答案是重跑 decide / verify,追加一条新记录。两条先后关系清楚的记录,比一条被改到认不出来的记录有用。

唯一允许原地改写的字段是 review。它不参与指纹,改它不会让记录的结论失效。评审命令是:

bash
d2c decision review .d2c/decisions/settings-form.12:401.json --status approved --reviewer skelanimals

输出:

json
{
  "schemaVersion": 2,
  "kind": "decision-review-result",
  "file": ".d2c/decisions/settings-form.12:401.json",
  "status": "approved",
  "reviewer": "skelanimals",
  "recordsReviewed": 1
}

我的用法是:评审盖在草稿上,盖完再 log-decision 进账本。账本里的行从此不再动。--status 只接受 approved 和 rejected,--reviewer 必填,CLI 层就拦,不给 MCP 侧留一个匿名盖章的口子。

3. FingerprintSet:老记录怎么失效

3.1 四条指纹从哪来

组件库发版、overrides 补一条 hazard、extractor 修了 slot 抽取,任何一环变了,昨天的 reuse 都可能不再成立。我不靠版本号,靠 FingerprintSet:

typescript
export const FingerprintSetSchema = z.object({
  extractor: z.string().min(1),   // 抽取器代码本身的哈希
  catalog: z.string().min(1),     // CatalogSnapshot 的整体哈希
  design: z.string().min(1),      // DesignIntentIR.source 的哈希
  source: z.string().min(1).optional(),  // 被验证源码的哈希 decide 时可能还没有
}).strict()

decide 写记录时,四条是这样填的:

typescript
fingerprints: {
  extractor: catalog.fingerprints.extractor,
  catalog: catalog.fingerprint,
  design: design.source.fingerprint,
  source: options.sourceFingerprint,   // 可选
},

catalog.fingerprint 本身又是一组指纹合出来的。catalog build 时会分别算 extractor / inventory / generated / overrides / links / sources 六条,再把六条加上编译后的 contracts 和 links 一起哈希:

typescript
const fingerprints = {
  extractor: catalogExtractorFingerprint(projectRoot),
  inventory: catalogInventoryFingerprint(inventory),
  generated: generatedFingerprint,
  overrides: overridesFingerprint,   // overrides 目录下所有 yaml 按路径排序后逐个哈希
  links: linksFingerprint,
  sources: sourcesFingerprint,
}
// 任何一层变了 整体指纹都变
const fingerprint = fingerprintContent(stableJson({ fingerprints, contracts: compiledContracts, links }))

哈希函数就是 sha256。多文件的情况按相对路径排序,路径和内容交替喂进去,路径不同或内容不同都会变:

typescript
export function fingerprintFiles(paths: string[], root = process.cwd()): string {
  const entries = paths
    .map(path => ({ path: relative(root, resolve(path)).replaceAll('\\', '/'), content: readFileSync(path) }))
    .sort((a, b) => a.path.localeCompare(b.path))   // 排序 保证遍历顺序不影响结果
  const hash = createHash('sha256')
  for (const entry of entries) {
    hash.update(entry.path)
    hash.update('\0')
    hash.update(entry.content)
    hash.update('\0')
  }
  return hash.digest('hex')
}

3.2 哪个变了会怎样

变化 哪条指纹变 账本里受影响的记录
overrides 里给 Button 补一条 hazard catalog 所有记录。不只 Button 的
组件库升级,导出的 d.ts 变了 catalog(经 generated / sources) 所有记录
修了 extractor 的 slot 抽取逻辑 extractor,同时 catalog 所有记录
设计师改了一个画板 design 只有这个画板下的记录
有人改了页面源码 source 只有引用这个文件的记录

第一行是最常见的集体失效事件。overrides 改一行,snapshot 指纹变了,账本里所有绑旧 catalog 指纹的 accepted,在新的 verify 里都会先栽在指纹义务上。有同事嫌烦,提过按组件粒度算指纹,改 Button 不影响 Input。我否掉了。一条 overrides 改的可能是 structureFamilies,会影响别的组件的候选入场;也可能是 hazard 的 scope,会影响整个目录。按组件切指纹等于让我去证明改动没有跨组件影响,这个证明比重跑 verify 贵。

3.3 verify 里的指纹义务

verify 入口先对四条指纹,每条一个 ProofObligation,id 是 boundary:fingerprint:<name>:

typescript
obligations.push(fingerprintProof('extractor', decision.fingerprints.extractor, catalog.fingerprints.extractor, evidence))
obligations.push(fingerprintProof('design', decision.fingerprints.design, design.source.fingerprint, evidence))
obligations.push(fingerprintProof('catalog', decision.fingerprints.catalog, catalog.fingerprint, evidence))
obligations.push(fingerprintProof('source', decision.fingerprints.source, usage.source.fingerprint, evidence))

function fingerprintProof(name: string, expected: string | undefined, actual: string, evidence: EvidenceRef[]): ProofObligation {
  // decision 里没有这条指纹 → unknown 理由 stale-fingerprint 不是 fail
  if (!expected) {
    const fact = unresolvedFact('stale-fingerprint', `${name} fingerprint is missing from the decision.`, evidence)
    return unknown(`boundary:fingerprint:${name}`, 'boundary', fact)
  }
  // 有但对不上 → fail 报告整单 rejected
  return expected === actual
    ? pass(`boundary:fingerprint:${name}`, 'boundary', evidence)
    : fail(`boundary:fingerprint:${name}`, 'boundary', `${name} fingerprint is stale.`, evidence, expected, actual)
}

要点:

  • 缺指纹落 unknown,对不上落 fail。前者是记录本身不完整,后者是世界变了
  • 指纹 fail 之后,后面的义务照样跑完。我要的是一份完整报告,不是在第一条就停
  • 报告的 disposition 跟 status 走,有 fail 就是 rejected。旧记录不会继续享受过期的 accepted

3.4 evolve 眼里的过期记录

evolve 本身不重新对指纹。它读的是账本里的 DecisionRecord 和 .d2c/verify/ 里的 VerifyReport,靠 decisionId 关联。报告是新 catalog 下跑出来的,旧记录的指纹对不上,报告 disposition 就是 rejected。所以过期记录不会作为成功样本进提案,反而可能作为失败样本进 hazard 提案。

这一点我一开始觉得别扭:catalog 一 rebuild,账本里一批旧 accepted 全变成失败样本,hazard 提案会突然变多。后来想明白了,这批提案是对的。它们在告诉人:你改了现行规则,这些旧判决要重审。人审的时候看一眼 failedObligations 全是 boundary:fingerprint:catalog,就知道是 rebuild 引起的,重跑 verify 重新 log 即可。提案只是把这件事摆到台面上。

4. review:approved 才是样本

DecisionRecord.review 的类型:

typescript
review: z.object({
  status: z.enum(['unreviewed', 'approved', 'rejected']),
  reviewer: z.string().min(1).optional(),
  reviewedAt: z.string().datetime().optional(),
}).strict().optional(),

evolve 的过滤条件只有一行:

typescript
const reviewed = decisions.filter(decision => decision.review?.status === 'approved' && decision.target)

unreviewed、rejected、根本没有 review 字段的,样本数为零。没有 target 的(verdict 是 new 或 needs-review),也为零。

我把这条写得这么硬是有原因的。decide 和 verify 会产出大量 needs-review 和 unknown,它们是问题清单,不是知识。如果 evolve 把待审的也当成曾经发生过,检索加权会被待审噪声顶上去,hazard 会被误报淹没。第 1 节那种口头的"可以过",只要没有落到 review 字段上,evolve 眼里就是零。这不是缺陷,是我要的行为。

评审人看的是:verdict 认不认、bindingPlan 认不认、对应 VerifyReport 的 disposition 认不认。三个都认,盖 approved。盖了章等于授权这条记录成为演进样本。

rejected 的记录也不是没用,但它不进 evolve。我把"人否了这次裁决"和"人认了这次裁决但 verify 挂了"分开:前者是机器判错了,不该变成组件的风险;后者才是这个组件在真实场景里栽过跟头,有资格进 hazard 统计。

5. evolve:从账本聚合出 EvolutionProposal

5.1 类型

我们先来看类型。EvolutionProposal 是 evolve 的唯一产物:

typescript
export const EvolutionProposalSchema = z.object({
  schemaVersion: z.literal(2),
  kind: z.literal('evolution-proposal'),
  id: z.string().min(1),                    // proposal:<kind>:<componentId>
  componentId: z.string().min(1),
  // 枚举留了四种 目前只有前后两种有生产者 semantic-role 和 extension-recipe 还没写
  proposalKind: z.enum(['retrieval-prior', 'semantic-role', 'hazard', 'extension-recipe']),
  samples: z.object({
    supporting: z.array(z.string().min(1)),  // 成功样本的 decision id
    failing: z.array(z.string().min(1)),     // 失败样本的 decision id
  }).strict(),
  statistics: z.object({
    successes: z.number().int().nonnegative(),
    failures: z.number().int().nonnegative(),
    successRate: z.number().min(0).max(1),
  }).strict(),
  patch: z.record(z.unknown()),             // 建议改动的形状 不是能直接 apply 的 diff
  requiredRegressionTests: z.array(z.string().min(1)).min(1),  // 至少一条 空数组过不了 schema
  evidence: z.array(EvidenceRefSchema).min(1),
  createdAt: z.string().datetime(),
}).strict()

patch 我特意用了 z.record(z.unknown())。它描述的是建议改什么,不是一个可以直接 apply 的 diff。这是第 7 节那次踩坑之后定下来的。

requiredRegressionTests 是 .min(1)。一条提案如果连该补什么测试都说不出来,schema 层就过不去。

5.2 聚合伪代码

proposeEvolution 全部逻辑不到一百行,思路在注释里:

typescript
export function proposeEvolution(decisions: DecisionRecord[], reports: VerifyReport[], createdAt: string): EvolutionProposal[] {
  // 报告按 decisionId 分组 一条 decision 可能有多份报告 静态一份 运行时几份
  const reportsByDecision = Map.groupBy(reports, report => report.decisionId)

  // 1. 过滤 只留人盖过 approved 且落到了具体组件的
  const reviewed = decisions.filter(decision => decision.review?.status === 'approved' && decision.target)

  // 2. 按目标组件分组 提案的粒度是组件 不是设计节点
  const grouped = Map.groupBy(reviewed, decision => decision.target!)

  const proposals: EvolutionProposal[] = []
  for (const [componentId, records] of grouped) {
    // 3. 看每条 decision 关联报告的结局
    const successes = records.filter(record => reportOutcome(reportsByDecision.get(record.id)) === 'accepted')
    const failures = records.filter(record => reportOutcome(reportsByDecision.get(record.id)) === 'rejected')

    // 4. 样本不够就不说话 成功少于 2 且没有失败 → 跳过
    if (successes.length < 2 && failures.length === 0)
      continue

    // 5. 证据从决定性的样本里摊平 needs-review 的报告不算证据
    const decisive = [...successes, ...failures]
    const evidence = decisive.flatMap(record => [
      ...record.evidence,
      ...(reportsByDecision.get(record.id) ?? [])
        .filter(report => report.disposition === 'accepted' || report.disposition === 'rejected')
        .flatMap(report => report.evidence),
    ])
    const base = {
      schemaVersion: 2, kind: 'evolution-proposal', componentId,
      samples: { supporting: successes.map(r => r.id), failing: failures.map(r => r.id) },
      statistics: { successes: successes.length, failures: failures.length,
        successRate: decisive.length ? successes.length / decisive.length : 0 },
      evidence, createdAt,
    }

    // 6. 成功 >= 2 → retrieval-prior 建议把 verifiedSupport 抬到成功次数
    if (successes.length >= 2) {
      proposals.push(EvolutionProposalSchema.parse({
        ...base,
        id: `proposal:retrieval-prior:${componentId}`,
        proposalKind: 'retrieval-prior',
        patch: { componentId, verifiedSupport: successes.length },
        requiredRegressionTests: [
          `Add a positive fixture for ${componentId}.`,
          `Add a competing-candidate negative fixture for ${componentId}.`,
          `Add a boundary fixture for ${componentId}.`,
        ],
      }))
    }

    // 7. 失败 > 0 → hazard 把失败的 obligation id 去重后列出来
    if (failures.length > 0) {
      proposals.push(EvolutionProposalSchema.parse({
        ...base,
        id: `proposal:hazard:${componentId}`,
        proposalKind: 'hazard',
        patch: {
          componentId,
          failedObligations: unique(failures.flatMap(record =>
            (reportsByDecision.get(record.id) ?? [])
              .flatMap(report => report.obligations)
              .filter(obligation => obligation.outcome === 'fail')
              .map(obligation => obligation.id))),
        },
        // 每条失败样本一条 要求复现成变异测试
        requiredRegressionTests: failures.map(record => `Reproduce ${record.id} as a failing mutation test.`),
      }))
    }
  }
  return proposals
}

// 一条 decision 的多份报告 任一 rejected 就算失败 有 accepted 且没有 rejected 才算成功
// 全是 needs-review 或者根本没报告 → undefined 不计入任何一侧
function reportOutcome(reports: VerifyReport[] | undefined): 'accepted' | 'rejected' | undefined {
  if (reports?.some(report => report.disposition === 'rejected'))
    return 'rejected'
  if (reports?.some(report => report.disposition === 'accepted'))
    return 'accepted'
}

5.3 两类提案和阈值

retrieval-prior:成功样本至少两条时发出。patch 是给这个组件的 verifiedSupport 抬到成功次数。含义是这个组件在真实裁决里多次验证通过,召回排序时可以靠前一点。它不改 verdict 规则,不把 unsatisfiable 变成 satisfiable,第 6.4 节讲。

hazard:失败样本大于零时发出。patch 带上失败义务 id 列表 failedObligations。含义是这个组件在人认过的裁决里栽过跟头,overrides 里应该留一条可检索的风险提示。义务 id 要能点回具体的 ProofObligation,比如 skeleton:12:340 或 contract:prop:resize,不是一句"用过出问题"。

同一个组件可以同时收到两类提案。既凑不够两次成功、又没有失败,就不提案。

阈值为什么是 2。一次蒙对在 Agent 工作流里太常见:场景简单、没有竞争候选、指纹碰巧稳定,第二次换个画板就翻车。单次成功就加分的诱惑很大,都绿了一次还不算数?我的回答是不算。2 也不是什么精确计算出来的数,它只是挡掉单次偶然的最小值。

约束:

  • reportOutcome 偏保守:多份报告里有一份 rejected,整条 decision 算失败
  • 只有 decision、没有报告的,不计入任何一侧。裁决没证明过,不是样本
  • 报告停在 needs-review 的,同样不计入。unknown 不是通过,这和整条管线一致

5.4 一个提案示例

第 1 节那个 Input 补完 recipe 之后,攒了三条 approved 记录,两条 accepted,一条运行时 rejected。evolve 会给出两条提案:

json
{
  "schemaVersion": 2,
  "kind": "evolution-proposals",
  "proposals": [
    {
      "schemaVersion": 2,
      "kind": "evolution-proposal",
      "id": "proposal:retrieval-prior:input",
      "componentId": "input",
      "proposalKind": "retrieval-prior",
      "samples": {
        "supporting": ["decision:settings-form:12:401", "decision:profile-form:31:77"],
        "failing": ["decision:search-bar:8:12"]
      },
      "statistics": { "successes": 2, "failures": 1, "successRate": 0.6667 },
      "patch": { "componentId": "input", "verifiedSupport": 2 },
      "requiredRegressionTests": [
        "Add a positive fixture for input.",
        "Add a competing-candidate negative fixture for input.",
        "Add a boundary fixture for input."
      ],
      "evidence": [
        { "source": "catalog-overlay", "locator": "tools/d2c/catalog/overrides/input.yaml", "extractorVersion": "2.0.0", "fingerprint": "a1b14be6...110680", "confidence": 1 },
        { "source": "figma-mcp", "locator": "figma://settings-form/12:401", "extractorVersion": "2.0.0", "fingerprint": "ef855063...7f9ad", "confidence": 1 }
      ],
      "createdAt": "2026-09-18T01:12:00.000Z"
    },
    {
      "schemaVersion": 2,
      "kind": "evolution-proposal",
      "id": "proposal:hazard:input",
      "componentId": "input",
      "proposalKind": "hazard",
      "samples": {
        "supporting": ["decision:settings-form:12:401", "decision:profile-form:31:77"],
        "failing": ["decision:search-bar:8:12"]
      },
      "statistics": { "successes": 2, "failures": 1, "successRate": 0.6667 },
      "patch": {
        "componentId": "input",
        "failedObligations": ["layout:geometry:8:12", "skeleton:8:12"]
      },
      "requiredRegressionTests": [
        "Reproduce decision:search-bar:8:12 as a failing mutation test."
      ],
      "evidence": ["同上 略"],
      "createdAt": "2026-09-18T01:12:00.000Z"
    }
  ]
}

samples 和 statistics 两条提案是一样的,因为它们来自同一组记录。差别只在 patch 和 requiredRegressionTests。

5.5 d2c evolve

我们执行一下:

bash
# 默认读账本 decision-log/v2/*.jsonl 和 .d2c/verify/ 下的报告
d2c evolve --out .d2c/proposals/2026-09-18.json

# 调试时可以显式指向草稿 这是逃生口 CI 里不该用
d2c evolve --decisions .d2c/decisions/drafts.json --reports .d2c/verify/reports.json

不带 --out 时结果直接打到 stdout,就是 5.4 那个 JSON。带 --out 时写一个文件,仅此而已。

service 层的实现:

typescript
evolve(options: { decisions?: unknown[], reports?: unknown[], writePath?: string } = {}) {
  // 默认输入是版本化账本 只追加的 jsonl
  // .d2c/decisions/ 是草稿 要读它必须显式传 --decisions
  const decisions = options.decisions
    ? options.decisions.map(value => this.parseDecision(value))
    : this.readJsonl(join(this.projectRoot, 'tools/d2c/decision-log/v2'), DecisionRecordSchema)
  const reports = options.reports
    ? options.reports.map(value => VerifyReportSchema.parse(value))
    : this.readArtifacts(join(this.projectRoot, '.d2c/verify'), VerifyReportSchema)
  const proposals = proposeEvolution(decisions, reports, this.now())
  const result = { schemaVersion: 2, kind: 'evolution-proposals', proposals }
  // 唯一的写操作 写的是提案文件 不是 catalog
  if (options.writePath)
    this.writeJson(options.writePath, result)
  return result
}

测试里有一条我很在意的断言:跑完 evolve 之后,snapshot.json 和 overrides 目录下的 yaml 内容要和跑之前逐字节一样。这条测试是第 7 节那次事故之后加的。

要点:

  • evolve 默认读账本,不读草稿。草稿进演进等于把没评审的噪声写进提案统计
  • 我自己犯过一次:把一整天的草稿目录传给 evolve,提案瞬间变胖,打开一看 supporting 全是没人盖章的记录
  • 没有 approved 样本时 evolve 输出空数组。安静是对的,不是坏了

6. 提案怎么落到 overrides

6.1 流程

flowchart TD
  draft[".d2c/decisions/ 草稿"] --> review["d2c decision review<br/>--status approved"]
  review --> log["d2c log-decision"]
  log --> ledger["decision-log/v2/YYYY-MM.jsonl<br/>只追加"]
  verify[".d2c/verify/ VerifyReport"] --> evolve
  ledger --> evolve["d2c evolve"]
  evolve -->|"review.status = approved 且有 target"| group["按 target 分组<br/>看报告 disposition"]
  group -->|"successes >= 2"| prior["proposal:retrieval-prior"]
  group -->|"failures > 0"| hazard["proposal:hazard"]
  prior --> human["人审提案<br/>看 samples / failedObligations / 回归测试"]
  hazard --> human
  human -->|"接受"| overrides["人改 overrides/*.yaml"]
  human -->|"驳回"| note["写驳回理由 提案文件留档"]
  overrides --> build["d2c catalog build && d2c catalog check"]
  build --> snapshot["新 CatalogSnapshot<br/>catalog 指纹变了"]
  snapshot -.->|"旧记录指纹失效"| ledger
  draft -.->|"不默认读取"| evolve

图里有两个虚线。一个是草稿到 evolve,默认不走。另一个是新 snapshot 回到账本,意思是 build 之后旧记录的 catalog 指纹全部对不上,下一轮 verify 会把它们标成 rejected。这两条虚线对应本篇的两条规矩:草稿不进演进,旧记录不继续有效。

6.2 人审看什么

接到一条 retrieval-prior,我的顺序是:

  • 先看 samples.failing。如果同一个组件同时有 hazard 提案,先处理红的,再谈加分
  • 抽两条 samples.supporting,打开对应的 DecisionRecord 和 VerifyReport,看 bindingPlan 和 obligation 是不是我认的那种绿
  • 看 requiredRegressionTests 三条有没有真文件对应。只有文字承诺不算
  • 最后才看 successRate。先看 id 再看数字,顺序反了容易被百分比带着走

接到一条 hazard,先复现。用 failedObligations 里的 id 和失败样本的指纹,本地重跑一次 verify,看能不能跑出同样的 fail。复现不了的多半是环境抖动,不该写进 overrides。复现得出的,再决定是改用法、补 recipe,还是给组件加一条 hazard。

三类回归测试里我最看重竞争负例。加了 verifiedSupport 之后,这个组件在 structure-index 里排到了前面。没有一个"另一个易混淆候选应该落败"的用例,就看不出加权有没有把邻居也吸进来。边界例卡的是 recipe 的 value 约束:recipe 允许 'none' | 'both',就要有一条传非法值应落 unknown 或 fail 的用例。

6.3 改 overrides 再 build

接受提案之后,人去改 overrides。以那个 Input 为例,接受 retrieval-prior 和 hazard 两条之后,yaml 大概是这样:

yaml
schemaVersion: 2
kind: component-overlay
id: input
roles:
  - input
  - text-field
# retrieval-prior 提案接受后 人手写进来 数字来自 statistics.successes
verifiedSupport: 2
hazards:
  # hazard 提案接受后 人手写进来 condition 和 message 是人写的 不是 patch 里抄的
  - id: search-bar-geometry
    severity: review
    scope:
      - 'app/pages/search/**'
    condition: input placed in a fill-width search bar without explicit width
    message: search bar geometry drifted in runtime verify; see decision:search-bar:8:12
extensionRecipes:
  - id: resize-control
    operations:
      - kind: set-prop
        targets:
          - resize
        constraints:
          resize:
            - kind: literal
              value: none
            - kind: literal
              value: both
    scopes:
      - '**'
autoMatch:
  eligible: true
  reviewed: true

然后:

bash
d2c catalog build
d2c catalog check

build 把 generated / overrides / links 三层合并,重算指纹,写 snapshot.json。check 校验 snapshot 和三层是否一致,CI 里跑。合并时 verifiedSupport 和 hazards 的规则是 overrides 优先:

typescript
// overlay 有就用 overlay 的 没有就用编译出来的
verifiedSupport: overlay.verifiedSupport ?? contract.verifiedSupport,
hazards: overlay.hazards?.map(hazard => ({ ...hazard, evidence })) ?? contract.hazards,

约束:

  • 提案 JSON 可以作为 PR 附件进讨论,但运行时只认 build 出来的 CatalogSnapshot。提案文件不是配置
  • PR 描述里要贴 proposal id 和三类回归测试的文件名或测试名,方便半年后的人回放
  • 接受提案不等于全盘接受。可以只收 recipe 不加 support,也可以只加 hazard。机器不替人按合并按钮

evolve 也不是改 overrides 的唯一入口。第 1 节那个 Input,缺的是菜单,不是检索权重。在攒够两次成功之前,人完全可以先把 resize-control 这条 recipe 补上。等提案才动手,反而拖延。

6.4 verifiedSupport 只影响排序

verifiedSupport 合进契约后,在 solver 里只做一件事:

typescript
// 批过次数折进 rankingScore:source 优先级不变,只在 structure-index 内部靠前。
function structureIndexEntry(contract: ComponentContractIR): RetrievedCandidate {
  return {
    componentId: contract.id,
    source: 'structure-index',
    ...(contract.verifiedSupport > 0 ? { rankingScore: contract.verifiedSupport } : {}),
  }
}

它没有否决权,不能把硬约束从 unsatisfiable 改成 satisfiable,也不能越过 identity-link 和 role-index 这两个优先级更高的召回源。这和第三篇讲的可搜不等于可自动选、第五篇讲的召回只排序,是同一条规矩。

我坚持这一点的理由:演进加分如果能改裁决,账本就会自我强化。偶尔绿一次,加权,更容易被选,更容易再绿,错的路径也被放大。写 overrides 的人如果指望 verifiedSupport: 10 就能跳过 hazard,会失望。severity 是 block 的 hazard 照样在 boundary:hazards 这条义务上 fail:

typescript
function hazardConstraint(context: CandidateContext): ProofObligation {
  // 只有 block 级且 scope 匹配的 hazard 才 fail review / warn 只给人看
  const active = context.contract.hazards.filter(hazard =>
    hazard.severity === 'block' && hazard.scope.some(pattern => matchesScope(context.scope, pattern)))
  return active.length
    ? fail('boundary:hazards', 'boundary', active.map(hazard => hazard.message).join('; '), active.flatMap(hazard => hazard.evidence))
    : pass('boundary:hazards', 'boundary', context.contract.evidence)
}

测试里有专门的用例:给契约抬高 verifiedSupport,确认它在 structure-index 里排到前面,同时确认硬约束失败时 verdict 仍然不是 reuse。这类测试要和提案合并绑在一起。没有"排序变了、裁决没变"的回归,就不合并 support 数字。

7. 踩坑:evolve 直接写 overrides

这一节讲我为什么坚持 evolve 只出提案。

早期的 evolve 不是这样的。那时候我觉得既然 verify 已经很严了,绿过的结果直接回灌到 catalog 应该没问题。所以 v1 的 evolve 在攒够 accepted 之后,直接打开对应组件的 overrides yaml,把 verifiedSupport 加一;连续 rejected 就直接往 hazards 数组里追加一条。写完自动 catalog build,整个流程不需要人。

出事的是一个 Table。一位同事之前手写了一条 hazard,severity: block,scope 是移动端页面目录,意思是这个 Table 在窄屏下不能直接用,要走另一个组件。这条 hazard 是人判断出来的,源码和账本里都没有任何东西能推出它。

evolve 那次跑的时候,Table 在桌面端页面攒了几条 rejected,于是要写 hazard。我写 yaml 的方式是读出来、改 JS 对象、序列化回去。问题在于读的时候用的是编译后的 contract,不是 overrides 原文。contract 里 hazards 是合并后的数组,evolve 生成新数组时是从自己的失败样本重新构造的,没有把原有的条目并进去。写回去之后,同事那条 block 级的 hazard 没了。

发现的时候已经过了一段时间。一个移动端页面 reuse 了这个 Table,decide 绿,verify 也绿,因为 boundary:hazards 义务看到的 hazards 里已经没有那条 block 了。真机上表格横向溢出。查 git blame,overrides 那个文件最近几次提交作者全是 CI bot,人写的那一行找不到是哪次被冲掉的。

那次之后我做了几件事:

  • evolve 不再碰 catalog 任何文件。产物只有 EvolutionProposal,patch 是 z.record(z.unknown()),故意不做成可 apply 的 diff
  • 加了一条测试:evolve 跑完,snapshot.json 和 overrides 目录逐字节不变
  • overrides 里的 hazards 和 verifiedSupport 只能由人在 PR 里改,CI 校验 schema,不替人合并
  • 提案里必须带 samples 的 decision id 和 requiredRegressionTests,没有回放能力的提案 schema 层就过不去

有人问过我,能不能做成半自动:evolve 生成 patch,人点一下确认就 apply。我也否了。人点一下确认和人手写一条 yaml,动作的差别是几秒钟,但前者会让人不看内容。那条被覆盖的 hazard,如果当时流程是人手写,评审的人打开 yaml 就会看到旧的那条在,不会漏。自动 apply 把这个看的机会拿掉了。

还有一类更隐蔽的自动写库,是把瞬时失败记成组件风险。环境抖一下、字体缺一下,运行时 verify 就 rejected,v1 的 evolve 就往 hazards 里写。Agent 开始回避本来好用的组件,转去 new 一个更差的实现。看起来是系统变保守了,其实是账本被没评审的红票喂胖了。现在 hazard 只是提案,人审的第一步就是复现,复现不了就驳回。

8. 走读:Input 的 resize 从 needs-review 到 recipe

把第 1 节那个 Input 用现在的流程走一遍。

第 1 步,decide 卡住。Agent 通过 providedBindings 传 resize: 'none'。Input 的 extensionRecipes 里没有声明这条 prop。solver 留下 undeclared-provided-binding,verdict 落 needs-review。

第 2 步,人看一眼。设计稿确实要求禁止拉伸,组件 API 也支持这个 prop,只是 recipe 没声明。这不是模型乱填,是 catalog 缺菜单。

第 3 步,不等 evolve。此时账本里可能有十几条类似的 needs-review,都没有 approved,evolve 看不见它们。正确动作是直接改 overrides,补 resize-control 这条 recipe,catalog build,对同一设计重跑 ingest、decide、verify。

第 4 步,验证过了盖章。新指纹下的 DecisionRecord 跑到 verify accepted,d2c decision review --status approved,再 d2c log-decision 进当月账本。

第 5 步,第二次、第三次同类成功。同一个 target: "input" 攒够两条 approved 加 accepted,d2c evolve 才可能打出 retrieval-prior。中途有一条运行时 rejected,就同时打出 hazard,带着失败的 obligation id。

第 6 步,人决定加不加分。看提案的样本和三类回归有没有 fixture。可以只收 recipe、暂不加 support;也可以两者都收。刚补上菜单的头几周,我通常只收 recipe。

第 7 步,build 之后回放。用同一组竞争候选跑 decide,带 support 的 Input 应该在 structure-index 内更靠前;但硬约束不满足时,仍然不得 reuse。如果发现 support 改变了 verdict,那是 bug。

这条走读想说的是:needs-review 的归宿常常是补 recipe,不是放宽 solver,也不是等 evolve。evolve 的 prior 是后面的事。

小结

本篇完成了:

  • decision ledger 的组织:decision-log/v2/<YYYY-MM>.jsonl 按月一个文件,只追加;.d2c/ 是草稿,不进账本
  • 一条 ledger 记录的样子,以及 log-decision 和 decision review 两个命令;review 是唯一允许原地改写的字段,因为它不参与指纹
  • FingerprintSet 四条指纹从哪来,catalog 指纹又由六条子指纹合成;哪一条变了会让哪些记录失效
  • verify 里 boundary:fingerprint:<name> 义务:缺指纹 unknown,对不上 fail
  • evolve 只读 review.status = approved 且有 target 的记录,按组件分组,看报告 disposition 定成功失败
  • EvolutionProposal 类型和聚合伪代码;retrieval-prior 要至少两次成功,hazard 有失败就发
  • d2c evolve 只写提案文件,不碰 catalog;人审之后人改 overrides,再 catalog build
  • verifiedSupport 只在 structure-index 内部排序,block 级 hazard 照样 fail
  • 早期 evolve 直接写 overrides 覆盖掉了一条人工 hazard,之后改成只出提案

下一篇是本系列最后一篇。讲上线后我收紧过的三处规则:undeclared-provided-binding 不再静默 set-prop、多余包层从 warn 改成 fail、设计有布局而 usage 无布局事实从跳过改成 runtime-required,以及这三处收紧为什么让 needs-review 上升。还有 Agent 和 MCP 在这条链路里该站哪:哪些工具可以自己调,哪些必须等人盖章。

系列 · 基于 FigmaMCP 的 D2C 工具 · 第 8 篇 / 共 9 篇
01基于 FigmaMCP 的 D2C 工具(一): 为什么同一份设计稿的生成结果会漂移02基于 FigmaMCP 的 D2C 工具(二): 为什么我不靠设计师再出一份干净稿03基于 FigmaMCP 的 D2C 工具(三): catalog——机器出事实,人补判断04 基于 FigmaMCP 的 D2C 工具(四): ingest——把 Figma 噪声收成 DesignIntentIR05基于 FigmaMCP 的 D2C 工具(五): decide——reuse / extend / new 的判定06 基于 FigmaMCP 的 D2C 工具(六): verify(上)——静态义务和 ComponentUsageIR07 基于 FigmaMCP 的 D2C 工具(七): verify——skeleton 与运行时量测
08基于 FigmaMCP 的 D2C 工具(八): 账本与 evolve——只提案,不自动改库本篇
09基于 FigmaMCP 的 D2C 工具(九): 我收紧过的规则,以及 Agent/MCP 怎么接进来
评论