基于 FigmaMCP 的 D2C 工具(九): 我收紧过的规则,以及 Agent/MCP 怎么接进来

20 分钟阅读

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

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

本篇是系列最后一篇,讲两件事。一是管线上线之后我收紧过的三处 fail-closed 规则:每次为什么收、收了什么、收完之后报表怎么变、同事怎么反对、我怎么回应。二是 Agent 通过 MCP 怎么接进这套引擎:server 暴露了哪些 handler,Agent 按什么顺序调,以及为什么它只能拿到 DecisionRecord 和 VerifyReport,拿不到改结果的口子。

我的目的是把规则变更和接法讲清楚 不是写一份 MCP 配置手册 所以下面贴的类型、handler 和 JSON 都按真实实现精简过 名字是真的 字段不全 客户端配置这里不展开

1. 上线之后报表先变红

管线跑通没多久,报表上 decide 的 needs-review 和 verify 的 unknown 比例明显上升。有同事发来一张周对比,左边是上一周的自动 reuse 比例,右边是这一周的,问我 “是不是回归了”。

不是。是我主动把三处原来会静默当 pass 的路径改成了 fail-closed。这三处以前不产出任何义务,报表上和通过长得一样;现在无法做出准确判断就记 unknown,把结论推向 needs-review。数字上升是我预期里的,我把它写进了这次发布的验收项。

把同一周的人工评审通过率叠上去看,曲线是平的。人没有多批几次驳回。这才是我关心的指标。

分布具体怎么变的,我按三个出口说一下。decide 这边,needs-review 的比例上来了,多出来的那部分几乎全是 unresolved.reason 为 undeclared-provided-binding 的记录,也就是 Agent 塞了没声明的 prop。verify 这边,status 为 unknown 的报告变多,对应的 disposition 从 accepted 移到了 needs-review,reason 以 runtime-required 为主,来源是空布局图和动态 slot 两条。账本这边,evolve 读到的 unknown reason 频次表里,这两个 reason 排到了前两位。fail 的比例基本没动,因为这三处收紧改的都是 “以前不产出义务” 的路径,不是把 pass 改成 fail。

要点:

  • accepted 变少不等于质量变差。是原来藏在绿色底下的未知翻到了明面
  • 该盯的是人工评审通过率,以及 unknown 被消化成 recipe / scenario 的速度
  • gold 集快照没变。三个 gold 用例都不带 providedBindings,也不走动态 slot 和空布局图,所以快照一个字节没动。真实流量里这三条很常见,线上分布会动

2. 三处收紧

2.1 设计有布局、源码侧没有布局事实

第七篇开头那个 Dialog 两栏变一栏的事故,静态阶段之所以绿着放过去,有一个前提:usage 布局图对那个节点是空的。utility 抽取器还没把布局类名抽全,或者生成结果只有组件标签没有布局类名,静态 verifier 拿不到源码侧的布局事实。

旧行为是不产出 layout 义务。没有义务就没有 fail,报表上等于跳过。这个旧行为是我自己早期写的。那时候 utility 抽取器还没接进来,usage 布局图几乎永远是空的,我把 “空图” 理解成 “没什么可比”,直接 return 了。后来抽取器接上,大部分节点有了布局事实,少数抽不到的节点还是走这条 return,而且和设计侧也没有布局意图的节点混在一起,从报告上分不出来。我把它改成落一条 unknown:

typescript
function skeletonLayoutObligations(designNode, usageNode, design, usage): ProofObligation[] {
  // 设计侧对这个节点没有布局事实 → 没什么可证 不产出义务
  // 收紧的是"有意图却假装无事" 不是凡组件必 runtime
  if (layoutFactCount(design.layout, designNode.id) === 0)
    return []
  // 设计有布局意图 但 usage 布局图是空的
  // 旧行为:这里也 return [] 报表上等于通过
  // 新行为:落一条 unknown 要求第七篇那套 runtime 补齐
  if (layoutFactCount(usage.layout, usageNode.id) === 0) {
    return [{
      id: `skeleton:${designNode.id}`,
      category: 'layout',
      phase: 'static',
      outcome: 'unknown',
      unresolved: unresolvedFact(
        'runtime-required',
        `Skeleton layout proof requires source-side layout facts; the usage layout graph has none for node ${usageNode.id}.`,
        usage.evidence,
      ),
      evidence: usageNode.evidence,
    }]
  }
  // 两边都有事实 → 正常走 skeleton 等价证明
  const results = proveSkeletonEquivalence({
    nodeMapping: { [designNode.id]: usageNode.id },
    designNodes: [designNode],
    usageNodes: [usageNode],
    designGraph: design.layout,
    usageGraph: usage.layout,
  })
  return skeletonProofToObligations(results)
}

layoutFactCount 数的是 axis、alignment、gap、padding、sizing、grid-tracks、wrap、overflow 这几类 relation。设计侧一条都没有的节点,仍然不会凭空长出义务。

收完之后,Dialog、分栏、工具条这类节点在静态报告里几乎一定会多出一条 skeleton:<nodeId> unknown,逼你要么补抽取,要么写 RuntimeScenario。这是预期工作量,我把它写进了迭代计划,不算异常。

2.2 动态 slot 名

BindingPlan 里有 fill-slot 操作,静态 verify 要在源码里找到那个 slot 被填了。源码写 <template #footer> 是字面量,能比。写 <template #[slotName]> 是动态名,静态看不出它填的是哪个槽。

旧行为是只要源码里有动态 slot,对任意期望 slot 一律判 pass。理由是 “它可能填了那个槽”。我见过动态名实际填的是别的槽,旧逻辑照样绿。那种绿比红危险。

typescript
if (operation.kind === 'fill-slot') {
  // 字面量精确匹配 或 default 槽下有非 template 子节点 → pass
  if (node.slots.some(slot => slot.name.kind === 'literal' && slot.name.value === operation.slot)
    || (operation.slot === 'default' && node.children.some(child => child.nodeKind !== 'template'))) {
    return pass(`structure:slot:${operation.slot}`, 'structure', operation.contractEvidence)
  }
  // 有动态 slot 名 → 旧行为直接 pass 新行为落 unknown 交给 runtime
  const dynamicSlot = node.slots.find(slot => slot.name.kind !== 'literal')
  if (dynamicSlot) {
    const fact = unresolvedFact(
      'runtime-required',
      `A dynamic slot name may fill slot ${operation.slot}; runtime proof is required.`,
      dynamicSlot.evidence,
    )
    return unknown(`structure:slot:${operation.slot}`, 'structure', fact)
  }
  // 既没字面量匹配 也没动态槽 → 还是 fail 这一条没变
  return fail(`structure:slot:${operation.slot}`, 'structure', `Slot ${operation.slot} is not filled.`, operation.contractEvidence)
}

要点:

  • 动态槽不是永远错,是静态证不了。证不了就该 runtime 或人看,不该在报表里冒充结构正确
  • 期望槽和字面量不一致、又没有动态槽的情况,仍然是 fail,这条我没动
  • 收紧之后生成器如果能改成字面量槽,静态路径立刻清爽。这是我想要的压力

2.3 未声明的 providedBindings

d2c decide 有一个入参 providedBindings,让调用方直接塞字面量 prop 进去。落到 BindingPlan 的 prop 值有四条来源:设计属性(来自 DesignIntentIR)、Figma link 的属性映射、variant rule,以及这条 providedBindings。前三条都有可审计的来源,第四条是调用方临时塞的,常常是 Agent 顺手加的。

旧行为是 providedBindings 只要过了 prop 类型校验,就静默生成一条 set-prop。等于 Agent 口头加菜单。我把它改成:只有目标组件的 extensionRecipes 声明了这个 prop,带 value 约束时值还要命中,才进 BindingPlan;否则落 unknown,reason 是新加的 undeclared-provided-binding,不进计划。

typescript
// buildBindingPlan 里对每个映射到的 prop 做的判断 精简
for (const [name, value] of mapped) {
  const prop = context.contract.props.find(item => item.name === name)
  if (!prop) {
    // 组件根本没这个 prop → fail 这是硬约束
    constraints.push(fail(`contract:prop:${name}`, 'contract', `Component ${context.contract.id} has no declared prop ${name}.`, context.evidence))
    continue
  }
  const proof = bindingProof(`contract:prop-value:${name}`, value, prop, context.evidence)
  if (proof.outcome === 'fail') {
    // 值不在类型或 validator 范围内 → fail
    constraints.push(fail(`contract:prop-value:${name}`, 'contract', `Binding for ${name} is outside the declared type or validator.`, prop.evidence, prop.type, value))
    continue
  }
  // 收紧点:来源是 provided 且没有 recipe 声明 → unknown 不进 operations
  if (provenance.get(name) === 'provided' && proof.outcome === 'pass' && !providedBindingDeclared(context, name, value)) {
    const fact = unresolvedFact(
      'undeclared-provided-binding',
      `Provided binding ${name} is not declared by an extension recipe for ${context.contract.id}; human review is required.`,
      context.evidence,
    )
    unresolved.push(fact)
    constraints.push(unknown(`contract:provided-binding:${name}`, 'contract', fact))
    continue
  }
  operations.push({ kind: 'set-prop', prop: name, value, designEvidence, contractEvidence: prop.evidence, source: provenance.get(name) })
  if (proof.outcome === 'pass')
    constraints.push(pass(`contract:prop:${name}`, 'contract', prop.evidence))
}

// 声明与否 就是在 scope 内找一条 set-prop 的 recipe
// findRecipe 里会顺带检查 constraints:recipe 对这个 prop 列了枚举 值就必须命中其中一个
function providedBindingDeclared(context, name, value): boolean {
  return findRecipe(context.contract.extensionRecipes, { kind: 'set-prop', target: name, value }, context.scope) !== undefined
}

一次带 providedBindings 的 decide 请求,收紧之后返回的 DecisionRecord 里会多出这样的东西:

json
{
  "verdict": "needs-review",
  "target": "input",
  "candidates": [
    {
      "componentId": "input",
      "outcome": "unknown",
      "constraints": [
        { "id": "contract:prop:placeholder", "outcome": "pass" },
        {
          "id": "contract:provided-binding:resize",
          "category": "contract",
          "outcome": "unknown",
          "unresolved": {
            "kind": "unresolved",
            "reason": "undeclared-provided-binding",
            "detail": "Provided binding resize is not declared by an extension recipe for input; human review is required."
          }
        }
      ],
      "unsatisfiedCore": []
    }
  ],
  "unresolved": [
    { "kind": "unresolved", "reason": "undeclared-provided-binding", "detail": "Provided binding resize is not declared by an extension recipe for input; human review is required." }
  ]
}

配套要做的事是补 recipe。这次 rollout 我给两个输入类组件补了 curated-provided-bindings,把反复出现的合法绑定写进 overrides。以 Input 的 resize 为例:

yaml
# catalog/overrides/input.yaml 精简
schemaVersion: 2
kind: component-overlay
id: input
extensionRecipes:
  - id: curated-provided-bindings
    operations:
      - kind: set-prop
        targets:
          - resize
        # 带枚举约束 塞别的值照样 undeclared-provided-binding
        constraints:
          resize:
            - kind: literal
              value: horizontal
            - kind: literal
              value: vertical
    scopes:
      - '**'
autoMatch:
  eligible: true
  reviewed: true

补完 d2c catalog build,catalog 指纹变了,旧裁决作废重跑。这是第三篇和第八篇铺过的规则,收紧之后它更显眼,也更值。

约束:

  • 我只收 providedBindings 这一条通道。设计属性、link、variant 三条不动,它们各有可审计来源
  • recipe 的 value 约束要写清楚。允许枚举就列枚举;允许任意字符串,就要接受这条 prop 后面更难证
  • 低频的一次性绑定走人工 review,不必硬塞进 recipe。overrides 膨胀成第二套组件文档是另一个坑

2.4 收紧前后的报表含义

flowchart LR
  subgraph before ["收紧前"]
    s1["usage 布局图为空: 不产出义务"] --> g1["报表像 pass"]
    s2["动态 slot 名: 一律 pass"] --> g1
    s3["未声明 providedBindings: 静默 set-prop"] --> g1
  end
  subgraph after ["收紧后"]
    u1["skeleton:node unknown<br/>runtime-required"] --> nr["status unknown<br/>disposition needs-review"]
    u2["structure:slot unknown<br/>runtime-required"] --> nr
    u3["contract:provided-binding unknown<br/>undeclared-provided-binding"] --> nr
  end
  before --> after

三处改在不同模块:第一处和第二处在静态 verifier,第三处在 solver。但对报表的影响同向,所以我把它们放在一次发布里一起讲,免得有人以为只是 verify 严了一点。

gold 集这边我定的规矩是 PR 确认制。收紧改了行为,gold 用例如果受影响,快照的变化必须在 PR 里逐条确认,不能一键更新。这次三个 gold 用例都没触发新路径,快照没动,但确认的流程照样走了一遍,diff 为空也要有人看过。

有同事提过加一个开关:严格模式 / 宽松模式,让业务方自己选。我否掉了。两个模式等于两套真相,同一份输入在两边得到两个 verdict,账本就没法读了。要宽松,去补 catalog 的判断层,别让引擎假装看清了。对内我保留了一个迁移期的 feature flag,只用来观察一周的分布变化,一周结束就删。留着它,半年后一定有人临时打开一下。

还有人建议加一个指标:unknown 的年龄。一条 undeclared-provided-binding 挂了四周没人补 recipe,说明的是治理问题,不是引擎太严。这条建议我收了。账本里按 reason 做一张频次表,比只看 needs-review 总数更有行动指向:高频的先变成菜单。

3. 收紧之后 overrides 要补什么

收紧不是为了让人天天点拒绝,是为了把重复劳动变成菜单。三处各有归宿:

  • usage 布局图为空:补 usage 抽取,或者补 RuntimeScenario 加 selector,让 runtime-required 有地方升级。scenario 模板我按 “打开 Dialog”、“切换 Tab”、“展开面板” 先做了三份,新页面多半是改 selector 和 mocks
  • 动态 slot:能改字面量就改字面量。必须动态的,在 scenario 里用 DOM 观察证明槽被填上,并在 review 记录里写清。不要为了静态绿去禁掉所有动态槽语法
  • providedBindings:高频合法 prop 写成 ExtensionRecipe,可以带允许值。Agent 仍然可以传 providedBindings,但只能点菜单上有的菜

补 recipe 的 PR 我要求附两条对照,防止有人补了菜单却补错约束:

bash
# 对照一:不带 recipe 时 应出现 undeclared-provided-binding
d2c decide --input .d2c/design/settings.json --scope app/pages/settings \
  --bindings bindings.json --out /tmp/without-recipe.json
d2c explain --input /tmp/without-recipe.json
# "unresolved": [{ "reason": "undeclared-provided-binding", ... }]

# 对照二:补上 recipe 重新 build 后 同一输入应进 BindingPlan
d2c catalog build
d2c decide --input .d2c/design/settings.json --scope app/pages/settings \
  --bindings bindings.json --out /tmp/with-recipe.json
d2c explain --input /tmp/with-recipe.json
# "bindings": [{ "kind": "set-prop", "prop": "resize", "source": "provided", ... }]

两条都跑通再合并。

overrides 还是人改。改完 build,指纹变了,旧裁决作废重跑。有同事觉得这一步太重,问能不能只重跑受影响组件的裁决。我没做。指纹是整份 snapshot 的,局部重跑要先证明 “哪些裁决不受影响”,那又是一套证明。目前的量级下全量重跑几分钟,我接受这个成本。

4. MCP server 是一层薄壳

4.1 CLI 和 MCP 共用同一个 D2CService

讲 Agent 接入之前,先看结构。CLI 和 MCP stdio server 调的是同一个 D2CService。匹配、约束、验证、演进的逻辑只在 service 和它下面的 solver / verifier 里有一份,adapter 层不实现任何一份 “更聪明的副本”。这条我写进了 README:adapters contain no matching logic。

MCP 的 handler 层就是这么薄:

typescript
export const MCP_TOOL_NAMES = [
  'd2c_catalog_build',
  'd2c_catalog_check',
  'd2c_ingest_figma_link',
  'd2c_ingest',
  'd2c_decide',
  'd2c_verify',
  'd2c_explain',
  'd2c_evolve',
  'd2c_search_catalog',
  'd2c_log_decision',
  'd2c_review_decision',
] as const

// 每个 handler 只是转发 不解析结果 不加默认值 不改字段
export function createMcpHandlers(service = new D2CService()) {
  return {
    catalogBuild: (args = {}) => service.catalogBuild(args),
    catalogCheck: () => service.catalogCheck(),
    ingestFigmaLink: (args: { input: unknown, write?: boolean }) => service.ingestFigmaLink(args),
    ingest: (args: Parameters<D2CService['ingest']>[0]) => service.ingest(args),
    decide: (args: Parameters<D2CService['decide']>[0]) => service.decide(args),
    verifyStatic: (args: Parameters<D2CService['verifyStatic']>[0]) => service.verifyStatic(args),
    verifyRuntime: (args: Parameters<D2CService['verifyRuntime']>[0]) => service.verifyRuntime(args),
    explain: (args: { input: unknown }) => service.explain(args.input),
    evolve: (args = {}) => service.evolve(args),
    searchCatalog: (args = {}) => service.searchCatalog(args),
    logDecision: (args: { decision: unknown }) => service.logDecision(args.decision),
    reviewDecision: (args: { file: string, status: 'approved' | 'rejected', reviewer: string }) => service.reviewDecision(args),
  }
}

参数类型直接用 Parameters<D2CService['decide']>[0] 这种写法从 service 上取,不另抄一份。service 的签名变了,handler 编译就报错,两边不会悄悄分叉。

4.2 register:成功和错误走同一个出口

typescript
function register(server: McpServer, name: string, description: string, inputSchema, handler) {
  server.registerTool(name, { description, inputSchema }, async (args) => {
    try {
      // 返回值原样 JSON 化 不做任何 "友好化"
      return jsonResult(await handler(args))
    }
    catch (error) {
      // schema 校验失败 / 文件不存在 / 组件不在 catalog 都走这里
      return errorResult(error)
    }
  })
}

export function jsonResult(data: unknown) {
  return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }
}

export function errorResult(error: unknown) {
  return {
    content: [{ type: 'text' as const, text: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }) }],
    isError: true as const,
  }
}

d2c_verify 是唯一一个在 server 层有分支的工具,按 mode 转给 verifyStatic 或 verifyRuntime,并检查各自的必填项。required 缺了直接抛,抛出去的报错文本是 “d2c_verify requires sourceFile” 这种,Agent 能读。

4.3 handler 列表

按真实注册顺序列一下每个工具的输入和输出。输入列只写关键字段,writePath 这类可选的输出路径省了:

工具 关键输入 输出
d2c_catalog_build componentIds?, write? CatalogBuildReport:status / summary / errors
d2c_catalog_check 无 CatalogCheckReport:snapshot 指纹与统计
d2c_ingest_figma_link input(Figma link), write? 校验后的 link;write 时顺带重新 build
d2c_ingest input, adapter: figma-rest / figma-mcp / design-intent-v2 DesignIntentIR
d2c_decide design, taskId, scope, providedBindings?, extensionRequests?, retrievedCandidates?, candidateUniverseComplete? DecisionRecord
d2c_verify mode, decision, design;static 要 sourceFile,runtime 要 scenario / baseURL / sourceFingerprint VerifyReport
d2c_explain input(DecisionRecord 或 VerifyReport) decision-explanation / verify-explanation
d2c_evolve decisions?, reports? evolution-proposals,只供人审
d2c_search_catalog query?, role?, limit? catalog-search:id / roles / autoMatch / completeness
d2c_log_decision decision decision-log-result:追加到哪个月的 jsonl
d2c_review_decision file, status: approved / rejected, reviewer decision-review-result

要点:

  • 工具接的是 JSON 对象,不是文件路径。只有 sourceFile、outputDir、writePath 这几个名字明确的字段才是路径
  • 工具名和 CLI 子命令一一对应:d2c_decide 对 d2c decide,d2c_review_decision 对 d2c decision review。同一份输入,两边产出的记录只差调用方指定的输出路径和时间戳
  • d2c_search_catalog 可搜不等于可 reuse。completeness / reviewed / autoMatch 这几道门在 decide 里照常起作用

5. 一次 Agent 调用长什么样

5.1 d2c_decide 的请求

Agent 拿到 d2c_ingest 返回的完整 DesignIntentIR 之后,原样塞进 design:

json
{
  "design": { "schemaVersion": 2, "kind": "design-intent", "id": "design:settings", "nodes": ["..."], "layout": { "relations": ["..."] } },
  "taskId": "settings-submit",
  "scope": "app/pages/settings",
  "designNodeId": "12:300",
  "providedBindings": {
    "resize": { "kind": "literal", "value": "vertical" }
  },
  "candidateUniverseComplete": true,
  "writePath": ".d2c/decisions/settings-submit.json"
}

design 这里省略了。schema 校验很严,schemaVersion 不是 2 直接报 “D2C v1 design IR is unsupported. Run d2c ingest first.”,字段不全也过不了 DesignIntentIRSchema.parse。Agent 不能自己拼一个看起来像的对象进来。

5.2 响应:DecisionRecord 精简

json
{
  "schemaVersion": 2,
  "kind": "decision",
  "id": "decision:settings-submit:12:300",
  "taskId": "settings-submit",
  "designIntentId": "design:settings",
  "designNodeId": "12:300",
  "fingerprints": { "extractor": "3c1f...", "catalog": "a9e2...", "design": "77b0..." },
  "candidateUniverse": { "scope": "app/pages/settings", "status": "complete", "fingerprint": "b4d1..." },
  "verdict": "reuse",
  "target": "input",
  "candidates": [
    {
      "componentId": "input",
      "retrieval": { "rank": 0, "sources": ["identity-link"] },
      "outcome": "satisfiable",
      "constraints": [
        { "id": "contract:prop:placeholder", "category": "contract", "outcome": "pass" },
        { "id": "contract:prop:resize", "category": "contract", "outcome": "pass" }
      ],
      "unsatisfiedCore": [],
      "bindingPlan": { "componentId": "input", "operations": ["..."] }
    }
  ],
  "bindingPlan": {
    "componentId": "input",
    "operations": [
      { "kind": "set-prop", "prop": "placeholder", "value": { "kind": "literal", "value": "请输入" }, "source": "design" },
      { "kind": "set-prop", "prop": "resize", "value": { "kind": "literal", "value": "vertical" }, "source": "provided" }
    ]
  },
  "unresolved": [],
  "review": { "status": "unreviewed" },
  "createdAt": "2026-09-01T02:14:33.000Z"
}

这是补了 recipe 之后的样子,resize 的 source 是 provided,能进 BindingPlan。没补 recipe 就是 2.3 节那份 needs-review。

Agent 读这份记录时要看的字段:verdict、target、bindingPlan.operations、unresolved、pendingObligations。写代码只能按 bindingPlan.operations 写,不能按聊天记忆发明 prop。

5.3 d2c_verify 的请求

json
{
  "mode": "static",
  "decision": { "schemaVersion": 2, "kind": "decision", "id": "decision:settings-submit:12:300", "...": "..." },
  "design": { "schemaVersion": 2, "kind": "design-intent", "...": "..." },
  "sourceFile": "app/pages/settings/index.vue",
  "writePath": ".d2c/verify/settings-submit.static.json"
}

运行时模式把 mode 改成 runtime,加 scenario、baseURL、sourceFingerprint。scenario 的写法第七篇讲过,这里不重复。

6. Agent 按什么顺序调

6.1 调用链

sequenceDiagram
  participant A as Agent
  participant M as MCP server
  participant S as D2CService
  participant E as solver / verifier
  A->>M: d2c_catalog_check {}
  M->>S: catalogCheck()
  S-->>A: CatalogCheckReport
  A->>M: d2c_ingest { input, adapter: figma-mcp }
  M->>S: ingest()
  S-->>A: DesignIntentIR
  A->>M: d2c_decide { design, taskId, scope }
  M->>S: decide()
  S->>E: solveReuse(design, catalog, options)
  E-->>S: DecisionRecord
  S-->>A: DecisionRecord
  Note over A: verdict = needs-review 则停下 列出 unresolved
  Note over A: verdict = reuse / extend 则按 bindingPlan 写代码
  A->>M: d2c_verify { mode: static, decision, design, sourceFile }
  M->>S: verifyStatic()
  S->>E: extractComponentUsage + verifyStatic
  E-->>S: VerifyReport
  S-->>A: VerifyReport
  Note over A: 有 runtime-required 则准备 scenario 再调一次 runtime
  A->>M: d2c_explain { input: VerifyReport }
  M->>S: explain()
  S-->>A: failed / unknown 义务列表 给人看

6.2 Agent 侧的伪代码

typescript
// Agent 编排层 只有流程控制 没有裁决逻辑
async function runTask(task: { taskId: string, scope: string, figmaNode: unknown }) {
  const check = await mcp.call('d2c_catalog_check', {})
  if (check.status !== 'pass')
    return stop('catalog 不可用 先修 overrides 或重新 build', check)

  const design = await mcp.call('d2c_ingest', { input: task.figmaNode, adapter: 'figma-mcp' })
  // NormalizationTrace 和 unresolved 要看 不能跳过
  if (design.unresolved.length)
    report('ingest 有未解析事实', design.unresolved)

  const decision = await mcp.call('d2c_decide', { design, taskId: task.taskId, scope: task.scope })
  if (decision.verdict === 'needs-review') {
    // 停下 把 obligation id 和 reason 原样列给人 不要自己改 verdict
    return stop('needs-review', decision.unresolved.map(fact => ({ reason: fact.reason, detail: fact.detail })))
  }
  if (decision.verdict === 'new')
    return stop('new: 候选全部 unsatisfiable 交给人决定是否新建组件', decision.candidates)

  // reuse / extend:只按 bindingPlan.operations 生成代码
  const sourceFile = await generateVue(decision.bindingPlan)

  let report = await mcp.call('d2c_verify', { mode: 'static', decision, design, sourceFile })
  const needsRuntime = report.obligations.some(o => o.outcome === 'unknown' && o.unresolved?.reason === 'runtime-required')
  if (needsRuntime) {
    // 缺 scenario 就停 不要在没有 scenario 的情况下宣称通过
    const scenario = await findOrDraftScenario(decision.designNodeId)
    if (!scenario)
      return stop('runtime-required 但没有 RuntimeScenario', report.obligations)
    report = await mcp.call('d2c_verify', { mode: 'runtime', decision, design, scenario, baseURL, sourceFingerprint: fingerprintOf(sourceFile) })
  }

  if (report.status !== 'pass') {
    const explanation = await mcp.call('d2c_explain', { input: report })
    return stop(`verify ${report.status}`, explanation.failed.concat(explanation.unknown))
  }
  // 到这里 Agent 的活干完了 log-decision 和 review 是人批准之后的事
  return done({ decision, report })
}

要点:

  • stop 的输出里必须有 obligation id 和 reason。没有 id,人没法对照第八篇的账本和本篇的收紧项
  • needs-review 就停,不要硬生成。缺菜单去补 overrides,缺 link 去补 link,缺 scenario 去写 scenario
  • d2c_log_decision 和 d2c_review_decision 不在这段流程里。进账本和盖章是人批准之后才做的事,我不让 Agent 在同一次运行里自动做

6.3 该做和不该做

Agent 该做的:

  • 调 d2c_catalog_check,坏快照就先停
  • 从 Figma MCP 拉节点,调 d2c_ingest,保存 DesignIntentIR
  • 调 d2c_decide,读 verdict、BindingPlan、unresolved
  • 按 BindingPlan 写代码
  • 调 d2c_verify static,有 runtime-required 再补 scenario 跑 runtime
  • 把缺 recipe、缺 link、缺 hazard 整理给人

Agent 不该做的:

  • 在提示词或编排层里改 verdict
  • 把 embedding 或聊天模型的相似度当成硬约束证据
  • 只跑静态、截图看着绿就告诉业务 “证明通过”
  • 自动改 overrides、自动 bump verifiedSupport
  • 用 providedBindings 塞未声明的 prop,然后要求引擎放行
  • 把 needs-review 重写成 reuse 再交给下游生成

分工就是:Agent 负责跑流程和搬数据,裁决只在引擎里做。提示词可以教它遇到 undeclared-provided-binding 就停下来列 prop,不能教它把 unknown 改成 pass。

和生成模型的分工也要划清。模型负责把 BindingPlan 落成 Vue / TS / CSS 文本,引擎负责那份计划是否合法、落地之后是否可证。模型可以提议加某个 prop,但提议必须再走一遍 providedBindings 到 recipe 检查的路,不能直接进仓库。Agent 如果跳过 ingest 直接看图写代码,那整单就退出了证明链路。那是另一条产品,不要和这套引擎混报成功率。

7. 为什么 Agent 绕不过 solver

这一节是我设计接口时最在意的一件事。Agent 拿到的只有两种东西:DecisionRecord 和 VerifyReport。它没有任何一个 handler 能把 verdict 或 status 当参数传进来。

设计 MCP 接口的时候有人提过一个方案:给 d2c_decide 加一个 overrideVerdict 参数,理由是 Agent 有时候比引擎更了解上下文,让它能把 needs-review 拉成 reuse,后面人再审。我反对的理由是:一旦 verdict 能从参数进来,DecisionRecord 上的 verdict 就不再是 solver 算出来的,账本里就分不清哪条是证出来的、哪条是喊出来的。第八篇讲的 evolve 只读 approved 记录,前提是 approved 的那条 verdict 本身可信。这个参数一加,前提就没了。所以我在四层把这条路封掉了。

7.1 handler 不做后处理

第 4 节的 createMcpHandlers 已经说明了:每个 handler 就是 service.xxx(args),返回值原样 JSON 化。没有一处 map、没有默认值填充、没有把 unknown 翻成警告字符串。要改结果,只能改 service 或它下面的 solver / verifier,而那两层有测试。

7.2 入口和出口都过 schema

service 每个方法收到的 decision 和 design 都先 parse 一遍,出去的记录也是 schema parse 之后才返回:

typescript
private parseDecision(value: unknown): DecisionRecord {
  if ((value as { schemaVersion?: unknown })?.schemaVersion !== 2)
    throw new Error('D2C v1 DecisionRecord is unsupported. Run `d2c decide` again.')
  // strict 模式 多一个字段都不行
  return DecisionRecordSchema.parse(value)
}

DecisionRecordSchema 的 superRefine 把 verdict 和候选结果绑死。手改 verdict 的记录再传回 d2c_verify 或 d2c_log_decision 时会在这里挂掉:

typescript
export const DecisionRecordSchema = z.object({ /* ... */ }).strict().superRefine((decision, ctx) => {
  const selected = decision.target ? decision.candidates.find(c => c.componentId === decision.target) : undefined

  if (decision.verdict === 'new') {
    // new 要求候选全集完整 且每个候选都是可解释的 unsatisfiable
    if (decision.candidateUniverse.status !== 'complete' || decision.candidates.length === 0 || decision.candidates.some(c => c.outcome !== 'unsatisfiable'))
      ctx.addIssue({ path: ['verdict'], message: 'New requires a complete universe and explainable unsatisfiability for every candidate.' })
    if (decision.target || decision.bindingPlan)
      ctx.addIssue({ path: ['target'], message: 'New decisions cannot select an existing component.' })
  }

  if (decision.verdict === 'reuse' || decision.verdict === 'extend') {
    // 把 needs-review 手改成 reuse:selected.outcome 还是 unknown 这里直接不过
    if (!selected || selected.outcome !== 'satisfiable' || !decision.bindingPlan || decision.bindingPlan.componentId !== decision.target) {
      ctx.addIssue({ path: ['target'], message: 'Reuse and extend require a satisfiable selected candidate and matching binding plan.' })
      return
    }
    const extensionOperations = decision.bindingPlan.operations.filter(op => op.extensionRecipeId)
    if (decision.verdict === 'reuse' && extensionOperations.length > 0)
      ctx.addIssue({ path: ['bindingPlan'], message: 'Reuse cannot apply extension-recipe operations.' })
    if (decision.verdict === 'extend' && extensionOperations.length === 0)
      ctx.addIssue({ path: ['bindingPlan'], message: 'Extend requires at least one declared extension-recipe operation.' })
  }

  // needs-review 必须有 unresolved 或 unknown 候选 不能凭空 needs-review
  if (decision.verdict === 'needs-review' && decision.unresolved.length === 0 && !decision.candidates.some(c => c.outcome === 'unknown'))
    ctx.addIssue({ path: ['unresolved'], message: 'Needs-review requires unresolved evidence.' })
})

候选这一层还有一道:CandidateEvaluationSchema 要求 satisfiable 的候选没有 fail、没有 unknown 约束、且带 bindingPlan。所以要把 needs-review 改成 reuse,得同时改 verdict、改候选 outcome、删掉 unknown 约束、补一份 bindingPlan,还要让 unresolved 清空。改到这个程度,等于自己重写了一份 DecisionRecord,指纹和 evidence 对不上,verify 会在 boundary:fingerprint:* 上挂掉。

VerifyReport 那边第七篇贴过,同一个思路:status 必须和 obligations 的 outcome 聚合结果一致,accepted 只允许出现在 status === 'pass' 的报告上。

7.3 review 只能盖章

d2c_review_decision 是唯一一个会改已有记录的工具,它能改的只有 review 字段:

typescript
reviewDecision(options: { file: string, status: 'approved' | 'rejected', reviewer: string }) {
  const lines = readFileSync(this.resolvePath(options.file, true), 'utf8').split('\n')
  const parsedLines = lines.map((line) => {
    if (!line.trim())
      return line
    // 先 parse 原记录 手改过的记录在这里就过不去
    const decision = DecisionRecordSchema.parse(JSON.parse(line))
    // 只写 review 三个字段 review 不参与 decision 指纹
    decision.review = { status: options.status, reviewer: options.reviewer, reviewedAt: this.now() }
    return JSON.stringify(decision)
  })
  writeFileSync(file, parsedLines.join('\n'))
  return { schemaVersion: 2, kind: 'decision-review-result', file, status: options.status, reviewer: options.reviewer, recordsReviewed: reviewed.length }
}

reviewer 是必填的非空字符串,工具描述里我写了 “Only call when a person confirmed the verdict in the current session”。这条是对 Agent 的约定,不是技术上的锁。技术上的锁是:review 只影响 evolve 读不读这条记录(第八篇),不影响 verdict 本身。approved 一条 needs-review,它还是 needs-review,verify 照样按 needs-review 处理。

我一开始没把这个工具暴露给 MCP,只留在 CLI。后来发现人审的时候经常是在 Agent 会话里看完解释直接想盖章,切到终端再敲一遍命令很烦,才加了进来。加的时候我坚持了两点:reviewer 必填,工具描述里写清只在人确认之后调。Agent 拿着这个工具能做的事,和人在终端敲 d2c decision review 一样多,不会更多。

7.4 evolve 只出提案,explain 不产生新结论

d2c_evolve 返回的是 evolution-proposals,不写 snapshot,不改 overrides。d2c_explain 只是把 DecisionRecord 或 VerifyReport 里的 fail / unknown 挑出来重排一下给人看,输入什么结论输出什么结论。这两个工具都没有写权限。

约束:

  • 改裁决语义,只许改 solver / verifier / schema,并且带测试
  • MCP 响应不做 “友好化”。有人想把 unknown 改成一段警告文案,我拦了:自动化客户端读的是字段,文案一变客户端全断。要友好,用 d2c_explain
  • CLI 的金样用例,MCP 路径复用同一断言。同一输入两条入口的对照测试抓过一个 bug:adapter 给可选字段填了默认值,MCP 侧指纹和 CLI 不一致

8. 踩坑:为通过率开后门

收紧之后的头几周,我收到过三类 PR。

第一类:给 providedBindings 加一个白名单环境变量,“内部 Agent 可信,跳过 recipe 检查”。第二类:把动态 slot 的 unknown 改回 pass,“页面手测没问题”。第三类:把空布局图重新变成跳过,“runtime 太慢,先保证演示”。

三类我都没合。理由分别是:一旦存在 “可信调用方” 通道,所有调用方都会声称自己可信,环境变量在 CI 里是谁都能设的;一旦动态槽无条件 pass,多余槽、错槽重新变成静默绿,2.2 节讲过那种绿比红危险;一旦空布局图可以跳过,第七篇那个 Dialog 事故就回到收紧前的状态。

后来我把这条写成了 code review 的规矩:看到 “为了 Agent 体验,decide 之后处理一下” 这类改动,默认不合,先要作者说明为什么不能改 solver。可以讨论的是某条 recipe 该不该更宽、某类场景该不该默认带 runtime、gold 集要不要扩、scenario 模板怎么减负。不可以讨论的是让报告在证据不足时继续说 pass。

演示日的压力最大。我和团队的约定是:演示可以用预置的、已经 approved 的案例,但不能为演示临时关收紧。关一次,就会有第二次 “只关一小时”。一小时关着门禁跑出来的绿票,事后没法证明它代表线上行为。

还有一种倒灌要单独说。仓库里已经有实现了,再补一条事后的 decide,把 verdict 调成 reuse。这种记录的 design 指纹和 source 指纹对不上当前源码,verify 在 boundary:fingerprint:* 上直接挂。看起来像引擎坏了,其实是顺序反了。推荐顺序是 catalog check → ingest → decide → 实现 → verify → review → log → evolve,本地调试可以打乱,那是人知道自己在跳步;Agent 的系统提示里写的必须是推荐顺序。

小结

整个系列完成了:

真实系统里还有不少东西这九篇没展开:检索服务什么时候从进程内索引拆出去、RuntimeScenario 的编写成本怎么用模板和录制压下来、多仓多组件库时账本怎么合并和分权限、Agent 编排层怎么强制推荐顺序而不在产品里复制一套裁决。这些大家可以根据自己的场景自行取舍。本系列只是想把这条主线讲清楚:设计稿进来,变成能校验的 IR,过一遍硬约束,出一份能审计的裁决和证明,再让人决定要不要沉淀。D2C 证明引擎系列到这里就告一段落了,后续有新的收紧或新的旁路事故,再单开文章和大家分享。

系列 · 基于 FigmaMCP 的 D2C 工具 · 第 9 篇 / 共 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 怎么接进来本篇
评论