基于 FigmaMCP 的 D2C 工具(九): 我收紧过的规则,以及 Agent/MCP 怎么接进来
在前八篇中,我们已经完成:
- 同一份设计稿为什么会生成出漂移的结果
- 为什么我把归一化做在工具侧,而不是让设计师再出一份干净稿
- 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:
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。理由是 “它可能填了那个槽”。我见过动态名实际填的是别的槽,旧逻辑照样绿。那种绿比红危险。
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,不进计划。
// 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 里会多出这样的东西:
{
"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 为例:
# 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 我要求附两条对照,防止有人补了菜单却补错约束:
# 对照一:不带 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 层就是这么薄:
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:成功和错误走同一个出口
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:
{
"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 精简
{
"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 的请求
{
"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 侧的伪代码
// 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 去写 scenariod2c_log_decision和d2c_review_decision不在这段流程里。进账本和盖章是人批准之后才做的事,我不让 Agent 在同一次运行里自动做
6.3 该做和不该做
Agent 该做的:
- 调
d2c_catalog_check,坏快照就先停 - 从 Figma MCP 拉节点,调
d2c_ingest,保存 DesignIntentIR - 调
d2c_decide,读 verdict、BindingPlan、unresolved - 按 BindingPlan 写代码
- 调
d2c_verifystatic,有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 之后才返回:
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 时会在这里挂掉:
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 字段:
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 的系统提示里写的必须是推荐顺序。
小结
整个系列完成了:
- 基于 FigmaMCP 的 D2C 工具(一): 为什么同一份设计稿的生成结果会漂移
- 基于 FigmaMCP 的 D2C 工具(二): 为什么我不靠设计师再出一份干净稿
- 基于 FigmaMCP 的 D2C 工具(三): catalog——机器出事实,人补判断
- 基于 FigmaMCP 的 D2C 工具(四): ingest——把 Figma 噪声收成 DesignIntentIR
- 基于 FigmaMCP 的 D2C 工具(五): decide——reuse / extend / new 的判定
- 基于 FigmaMCP 的 D2C 工具(六): verify(上)——静态义务和 ComponentUsageIR
- 基于 FigmaMCP 的 D2C 工具(七): verify——skeleton 与运行时量测
- 基于 FigmaMCP 的 D2C 工具(八): 账本与 evolve——只提案,不自动改库
- 基于 FigmaMCP 的 D2C 工具(九): 我收紧过的规则,以及 Agent/MCP 怎么接进来
真实系统里还有不少东西这九篇没展开:检索服务什么时候从进程内索引拆出去、RuntimeScenario 的编写成本怎么用模板和录制压下来、多仓多组件库时账本怎么合并和分权限、Agent 编排层怎么强制推荐顺序而不在产品里复制一套裁决。这些大家可以根据自己的场景自行取舍。本系列只是想把这条主线讲清楚:设计稿进来,变成能校验的 IR,过一遍硬约束,出一份能审计的裁决和证明,再让人决定要不要沉淀。D2C 证明引擎系列到这里就告一段落了,后续有新的收紧或新的旁路事故,再单开文章和大家分享。