跳到主要内容

数据截至 (上游 commit b084ab075ba2)

文件系统即产品:skills、设计系统与插件三类资产

30 秒导读: Open Design 把"内容"整个放在磁盘上——每个 skill 是一个带 SKILL.md 的目录,每套设计系统是一个带 DESIGN.md + tokens.css 的目录,每个插件是一个带 open-design.json 的目录。daemon 不解析这些正文的语义,它只负责发现它们、把 frontmatter 归一化成类型、校验产物是否自洽、并把某一次用到的东西钉成快照。本章讲这三类资产在磁盘上长什么样、daemon 用哪些函数把它们抬进内存。

本章不讲这些正文最终怎么拼进 prompt——那是 提示词工厂 的事。


1. 这是什么(零基础也能懂)

一句话定义: Open Design 的"内容层"是三个仓库顶层目录,里面全是人可读、agent 可直接读的 Markdown 与 JSON。

它解决什么问题。 一个设计 agent 要好用,需要三种知识:怎么做(工作方法)、做成什么样(品牌视觉)、跟谁配合(可安装的能力包)。这三种知识要能被人手写、被 PR review、被 diff、被 git 追踪——所以最合适的载体是文件,不是数据库行。

三类资产各是什么:

资产磁盘位置必需文件一句话职责
Skillskills/<id>/SKILL.md教 agent 一种做法("用 Apple HIG 的规范做 iOS 界面")
设计模板design-templates/<id>/SKILL.md同一套格式,但偏"渲染目录"(deck / 海报 / live artifact)
设计系统design-systems/<id>/DESIGN.md一个品牌的完整视觉规范 + 可粘贴的 CSS token
插件安装到 <dataDir>/plugins/<id>/SKILL.mdopen-design.json一个可分发、带流水线声明、带能力申请的包

仓库里现在有多少(as-of 本 commit,ls -d 实数):

目录子目录数校验
skills/157157 个都有 SKILL.md
design-templates/112112 个都有 SKILL.md
design-systems/151150 个有 DESIGN.md + manifest.json,第 151 个是 _schema/ 契约目录

用起来什么样。 一个最小的 skill 就是两行 frontmatter 加一段正文,样本见 skills/apple-hig/SKILL.md

---
name: apple-hig
description: |
Apple Human Interface Guidelines as 14 agent skills covering platforms,
foundations, components, patterns, inputs, and technologies...
triggers:
- "apple hig"
- "ios design"
od:
mode: design-system
category: design-systems
upstream: "https://github.com/raintree-technology/apple-hig-skills"
---

# apple-hig
...

name 就是 id,od.* 是 Open Design 私有的机器可读命名空间,--- 之后的正文原样进 prompt。加一个 skill = 新建一个文件夹,不需要改任何代码、不需要迁移数据库。

一句话直觉。 把这三个目录当成 agent 的"教材柜":产品做的是图书管理员的活——编目、贴标签、检查书有没有缺页、借书时登记一张借书单;书里写什么,管理员不看。


2. 顶层全景(它大概怎么转)

三类资产的处理路径不同,但骨架是同一条五步流水线。从左到右读,每一步的产物喂给下一步:

磁盘 发现 归一化 校验 钉住
┌──────────┐ ┌───────────┐ ┌────────────┐ ┌───────────┐ ┌──────────┐
│ skills/ │────▶│ 扫目录 │────▶│ frontmatter│────▶│ (skill 无) │────▶│ 项目里存 │
│ <id>/ │ │ listSkills│ │ → SkillInfo│ │ │ │ skillId │
└──────────┘ └───────────┘ └────────────┘ └───────────┘ └──────────┘

┌──────────┐ ┌────────────────┐ ┌──────────┐ ┌────────────┐ ┌──────────┐
│ design- │────▶│ listDesign │─▶│ manifest │───▶│ token 契约 │───▶│ 内容指纹 │
│ systems/ │ │ Systems │ │ + 正文 │ │ 自检+评分 │ │ sha256 │
└──────────┘ └────────────────┘ └──────────┘ └────────────┘ └──────────┘

┌──────────┐ ┌────────────────┐ ┌──────────┐ ┌────────────┐ ┌──────────┐
│ plugins/ │────▶│ resolvePlugin │─▶│ 三源合并 │───▶│ doctor / │───▶│ Applied │
│ <id>/ │ │ Folder │ │ merge │ │ validate │ │ Snapshot │
└──────────┘ └────────────────┘ └──────────┘ └────────────┘ └──────────┘

部件一句话职责:

部件干什么在哪个文件
listSkills扫多个根目录,把每个 SKILL.md 变成一条 SkillInfoapps/daemon/src/skills.ts:232
parseFrontmatter零依赖的 YAML 子集解析器,三类资产共用apps/daemon/src/design-systems/frontmatter.ts
listDesignSystems扫品牌目录,合并 manifest / 正文 / 用户元数据apps/daemon/src/design-systems/index.ts:319
buildDesignTokenContract把扫出来的源 token 绑定到 56 项固定 schema,打分apps/daemon/src/design-systems/token-contract.ts:142
resolvePluginFolder从三种清单文件里解析出一份 PluginManifestapps/daemon/src/plugins/registry.ts:98
applyPlugin纯函数:清单 + 输入 → 一份可复现的应用结果apps/daemon/src/plugins/apply.ts:91
createSnapshot把这次应用结果写死成一行不可变快照apps/daemon/src/plugins/snapshots.ts:66

主线走一遍。 用户在界面上选了 skill apple-hig + 设计系统 airbnb + 插件 od-new-generation:daemon 分别调 listSkills / resolveDesignSystemAssets / applyPlugin 拿到三份内存对象;skill 与设计系统的正文交给 prompt 工厂拼接(见 第 3 章),插件那份则先落成 AppliedPluginSnapshot 行,run 从此刻起只认这张快照——上游文件后来怎么改都不影响这次 run。


3. Skill:一个目录 + 一份 SKILL.md

这节讲 skill 从磁盘变成 SkillInfo 的全过程,以及"用户改内置 skill"这件事怎么在只读仓库上实现。

3.1 多根扫描:第一个根赢

listSkills 接受一个根数组而不是单个路径,按顺序扫,id 撞车时第一个根胜出(apps/daemon/src/skills.ts:232-284)。这是"用户影子覆盖内置"的全部实现——不删内置文件,只是让它在列表里被挡住。

source 字段就是根的序号:

const source: SkillSource = rootIdx === 0 ? "user" : "built-in"; // skills.ts:154

守卫写在读完 name 之后、读其余 frontmatter 之前,让被遮蔽的 skill 走最短路径(skills.ts:284):

if (seenIds.has(parentId)) continue;
seenIds.add(parentId);

daemon 侧一共配了三组根(apps/daemon/src/server.ts:1220-1226):

根组组成(优先序)谁用
SKILL_ROOTS<dataDir>/skills → 仓库 skills/Settings → Skills
DESIGN_TEMPLATE_ROOTS<dataDir>/design-templates → 仓库 design-templates/入口页的 Templates 画廊
ALL_SKILL_LIKE_ROOTS上面四个全串起来聊天 run 组 prompt 时,因为存下来的 id 可能来自任一侧

拆成两套目录是 PR #955 的"skills / design-templates 分家":skills/ 留 agent 中途调用的功能性技能,design-templates/ 放大体量的渲染目录。

3.2 frontmatter 怎么被归一化

SKILL.md 的 frontmatter 是自由的SkillInfo收紧的。中间这层归一化函数全部遵守同一条纪律:认不出来就退回默认值,绝不抛错——因为这是发现流程,不是校验流程(skills.ts:413-415catch 注释原话:this is discovery, not validation)。

frontmatter 字段归一化函数认不出时的行为
od.modenormalizeMode (skills.ts:745)掉进 inferMode,用正则在正文里猜 image/video/deck/…
od.surfacenormalizeSurface (skills.ts:754)跟随 mode;web 兜底
od.platformnormalizePlatform (skills.ts:766)只有 prototype 才有值,从正文里嗅 `mobile
od.categorynormalizeCategory (skills.ts:799)只放行 [a-z0-9-],截 64 字符,否则 null
od.craft.requiresnormalizeCraftRequires (skills.ts:621)逐项 slug 校验 + 去重;缺文件在组 prompt 时静默跳过
od.critique.policynormalizeCritiquePolicy (skills.ts:697)只认 required/opt-in/opt-out,其余 null
od.featurednormalizeFeatured (skills.ts:708)true → 1;非数字 → null(回落字母序)
od.example_promptderivePrompt (skills.ts:722)没写就取 description 第一句,截 320 字符

normalizeCritiquePolicy 值得单独说,因为它是质量闭环的最高优先级开关。它返回的三值决定第二个 agent 要不要上场(细节见 第 6 章):

export function normalizeCritiquePolicy(value: unknown): SkillCritiquePolicy {
const v = value.trim().toLowerCase();
if (v === "required" || v === "opt-in" || v === "opt-out") return v;
return null; // 无意见 → 让 project / env / phase 三层默认去决定
}

skills.ts:694-696 明确说明它被单独导出的原因:为了让测试能在不碰文件系统的前提下钉住 trim / 小写 / 拒绝拼写错误这三条行为。

样例:一个"重"skill 长什么样skills/impeccable-design-polish/SKILL.md),它同时用上了 category、craft、design_system 三组:

od:
mode: prototype
surface: web
platform: desktop
category: creative-direction
upstream: "https://github.com/pbakaus/impeccable"
design_system:
requires: true
craft:
requires: [typography, color, anti-ai-slop, animation-discipline, accessibility-baseline]

craft.requires 里的每个 slug 对应仓库 craft/<slug>.md 一份"品牌无关的手艺规则"。skills.ts:615-620 的注释点破了这个设计:不做 daemon 侧白名单,加一节手艺 = 丢一个文件进 craft/ 再在 skill 里列上名字。

3.3 派生 skill:一个目录,一排卡片

它要解决的小问题。 design-templates/live-artifact/ 底下躺着七八个手工做好的样例 HTML。给每个样例单独建一个 SKILL.md 太重,但只挂一张卡片又展示不出样例的多样性。

思路。<dir>/examples/*.html,每个 .html 合成一张派生卡片,id 用 <父 id>:<文件基名> 这种合成形式:

skills 列表输出(一次 listSkills 的 out[])
┌────────────────────────────────────────────┐
│ live-artifact ← 父,仍在列表里(供 id 解析 / 组 prompt)
│ aggregatesExamples: true ← 前端据此把父卡片从画廊里藏掉
├────────────────────────────────────────────┤
│ live-artifact:stock-dashboard ← 派生,继承父的 mode/surface/platform/body
│ live-artifact:crm-table-live ← 派生
│ live-artifact:baby-health-live ← 派生
└────────────────────────────────────────────┘

三个配套函数:

函数位置作用
collectDerivedExamplesskills.ts:467只认 examples/<name>.html 单文件布局,排序保证画廊每次刷新顺序一致
splitDerivedSkillIdskills.ts:525a:b 拆回父子;非派生 id 返回 null 让调用方回落普通查找
resolveDerivedExamplePathskills.ts:517把子 key 还原成磁盘路径,供 /api/skills/:id/example

三者共用同一个守门函数 isSafeExampleKeyskills.ts:491):拒绝以 . 开头、拒绝含 :、只放行 [A-Za-z0-9._-]。这一条同时挡住了路径穿越和"子 key 里再冒出一个冒号把 id 格式搅乱"。

刻意不做的事。 skills.ts:454-466 的注释交代得很清楚:子目录布局(examples/<name>/template.html + data.json故意不暴露——那种模板里还留着 {{data.x}} 占位符,只有 daemon 侧渲染器能填,直接摆进画廊会露出花括号,比不显示更糟。要上架就把烤好的成品放成 examples/<name>.html

派生卡片继承父的 bodyskills.ts:409),否则点"用这个 prompt"会组出一份空的 system prompt;但不继承 featuredskills.ts:394),免得派生卡片把首页推荐位挤爆。

3.4 side files:告诉 agent"你的教材在哪"

问题。 agent 的工作目录是项目文件夹(.od/projects/<id>/),而 skill 的 assets/template.html 在别处。SKILL.md 正文里写的相对路径会解析到错误的地方。

解法。 只要目录里除了 SKILL.md 还有别的东西(dirHasAttachmentsskills.ts:602),就在正文前面注入一段前言,同时给两条路径(withSkillRootPreambleskills.ts:556):

SKILL.md 正文


┌──────────────────────────────────────────────┐
│ ① Skill root(相对): .od-skills/<folder>/ │ ← 首选,聊天前把 skill 拷进 cwd
│ ② Skill root(绝对): /repo/skills/<id> │ ← 兜底,Claude/Copilot 另给 --add-dir
│ ③ Known side files: `assets/x.html`, … │ ← collectReferencedSideFiles 扫出来的
└──────────────────────────────────────────────┘


原正文

相对路径优先是有原因的:某些 CLI 的目录访问策略会拦掉 cwd 之外的绝对路径(issue #430)。而"该用哪条"的判断逻辑写在前言正文里给 agent 自己读,而不是 daemon 侧做 CLI 特性探测——这是把决策下推给模型的一个典型例子。

collectReferencedSideFilesskills.ts:594)只用一条正则扫正文里的 assets/…references/…,外加特判 example.html,把命中项列进前言。

3.5 别名表:文件夹改名不该炸掉老项目

id 是从 frontmatter 的 name 推出来的,所以 skill 一改名,存在老项目 metadata 里的 id 就再也解析不到,而 composeSystemPrompt静默地丢掉 skill 正文——不报错,只是生成质量莫名其妙掉一截。

修法是一张冻结的转发表(skills.ts:28-32):

export const SKILL_ID_ALIASES = Object.freeze({
"editorial-collage": "open-design-landing",
"editorial-collage-deck": "open-design-landing-deck",
"taste-skill": "design-taste-frontend",
});

配套的 findSkillByIdskills.ts:148)先过 resolveSkillId 再 find。注释里写死了纪律:每一处解析外部/存储 id 的地方都得走它,直接 .find() 会静默漏掉别名;条目至少保留一个稳定版本再删。

3.6 用户写入:import / update / delete

用户侧 skill 全住在 <dataDir>/skills/<slug>/——就是 §3.1 那张表里 SKILL_ROOTS 的第一个根(USER_SKILLS_DIR = path.join(RUNTIME_DATA_DIR, 'skills')server.ts:834)。daemon 把这个目录当作完全自有,所以三个写操作都很直白:

函数位置语义
importUserSkillskills.ts:926新建;目标 slug 已存在则报 CONFLICT,不覆盖
updateUserSkillskills.ts:1009覆写;首次影子化内置 skill 时顺便克隆 side files
deleteUserSkillskills.ts:1155删目录;删之前二次校验 target 落在 root 内
listSkillFilesskills.ts:1112走一遍目录树给详情面板用,上限 500 条 / 6 层

读源码时的一个坑:注释比代码旧。 skills.ts:832 那段章节注释仍写着 "User-imported skills live under <runtimeData>/user-skills/<slug>/SKILL.md",skills.ts:1167 的防御性注释里也还叫 "user-skills"。这两处都是改名前的残留:三个写函数的根目录是参数updateUserSkill(userSkillsRoot, input)),路由层传进去的实参是 USER_SKILLS_DIRroutes/static-resource.ts:584:266),落地目录因此是 <dataDir>/skills/。以常量为准,不以注释为准。

slugifySkillNameskills.ts:857)把名字压成 [a-z0-9-_]、拒绝 ./..、截 64 字符。deleteUserSkill 在此之上还做了纵深防御(skills.ts:1166):

if (target !== dir || !target.startsWith(root + path.sep)) {
throw new SkillImportError("BAD_REQUEST", "invalid skill path");
}

updateUserSkill 的克隆细节是本节最容易踩的坑skills.ts:1039-1060)。第一次编辑一个内置 skill 时,listSkills 会把影子目录提升成活跃 dir,但影子里只有用户刚写的 SKILL.md——原来的 assets/references/examples/ 全从解析器视野里消失了。所以只在 !dirExisted && sourceDir !== dir 这一个瞬间调 cloneSkillSideFilesskills.ts:1071)把除 SKILL.md 与点文件之外的东西 cp -r --dereference 过来;之后再编辑就绝不重复克隆,否则会覆盖用户自己改过的附件。克隆失败是非致命的:SKILL.md 照样落盘,side file 解析器退化成对单个条目 404。


4. 设计系统:DESIGN.md 是散文,tokens.css 是契约

这节讲一套品牌在磁盘上摊开长什么样,以及 daemon 怎么把"DESIGN.md 里的颜色说法"反向核对成一份可打分的 token 契约。

4.1 磁盘形态

design-systems/airbnb/ 当样本,一个完整包是这样:

文件谁读一句话
DESIGN.mdagent(进 prompt)9 节结构化散文:视觉气质、色板、字体、间距、组件惯例
USAGE.mdagent"怎么用这套系统"的操作说明
tokens.cssagent(原样粘进 <style>:root{} 里的 56 项 schema token
design-tokens.json下游工具同一批 token 的 JSON 派生形态
tailwind-v4.css下游工具Tailwind v4 @theme 派生形态
components.htmlagent参考组件夹具,值全部走 var(--*)
components.manifest.jsondaemon夹具的机读摘要:声明了哪些 token、多少选择器/类/元素
manifest.jsondaemon包清单:文件名映射、预览页、来源、craft 声明
preview/*.htmlcolors / typography / spacing 三张预览页
system/人 + 静态路由渲染好的 kit(含 dark)与 landing/deck/poster 等成品
source/daemon导入证据:evidence.mdtokens.source.jsontoken-contract.report.json
DESIGN-{zh,ja,fr,…}.md17 种语言的 DESIGN.md 译本

关于译本文件,诚实说明:在 daemon 源码里找不到读取 DESIGN-<lang>.md 的路径listDesignSystems 只读 manifest.files.design ?? 'DESIGN.md',静态文件白名单(index.ts:886isAllowedDesignSystemStaticFile)也只放行那一个。它们目前是给人看的仓库资产。

manifest.json 的形状(design-systems/airbnb/manifest.json):

{
"schemaVersion": "od-design-system-project/v1",
"id": "airbnb",
"category": "E-Commerce & Retail",
"files": { "design": "DESIGN.md", "tokens": "tokens.css",
"designTokens": "design-tokens.json", "tailwind": "tailwind-v4.css",
"components": "components.html" },
"usage": "USAGE.md",
"componentsManifest": "components.manifest.json",
"importMode": "normalized",
"craft": { "applies": [], "suggested": ["color", "accessibility-baseline"], "exemptions": [] },
"preview": { "dir": "preview", "pages": [ { "path": "preview/colors.html", "role": "colors" } ] },
"sourceFiles": { "evidence": "source/evidence.md", "tokens": "source/tokens.source.json",
"report": "source/token-contract.report.json" }
}

manifest 是可选的listDesignSystemsmanifest?.files.design ?? 'DESIGN.md' 兜底,只有 DESIGN.md 的老式目录照样是合法品牌(index.ts:1-9 的模块头注释)。

4.2 listDesignSystems:四路来源的优先级

同一个字段可以从四个地方来,listDesignSystemsindex.ts:268-340)定死了顺序。这是本文件里最容易读错的一段,列成表:

字段优先级(左高右低)
title用户元数据 → manifest.name → 正文首个 H1 → frontmatter name → 目录名
category用户元数据 → manifest.category → 正文 > Category: 引用行 → frontmatter category"Uncategorized"
summarymanifest.description → 正文首段 → frontmatter description → 空串
surface用户元数据 → 正文提取 → frontmatter → 'web'
swatchespickFinalSwatchRow:frontmatter 色板只有填满每一个语义槽时才压过 Markdown 色板

最后那条是 issue #1857 的产物(模块头注释点名):Google 规格的 frontmatter colors 常常只写一半,半张色板不如正文里手写的完整,所以只在"填满"时才让它赢。

4.3 资产读取:指纹缓存 + 内容摘要

resolveDesignSystemAssetsindex.ts:602)是组 prompt 时的入口。它做三件事:

① 开关。 OD_DESIGN_TOKEN_CHANNEL=0 时整条通道返回全 undefinedisDesignTokenChannelEnabledindex.ts:560)——一个可以整体关掉 token 注入的逃生阀。

② 内容指纹缓存。 缓存 key 不是 id,而是把五个候选文件的 size/mtimeMs/存在性哈希成的指纹(designSystemAssetsCacheFingerprintindex.ts:688)。文件一改指纹就变,缓存自然失效——不用 watcher,也不会读到陈旧内容。缓存上限 128 条,pruneDesignSystemAssetsCache 按插入序淘汰(index.ts:680)。

③ 双根回落。 resolveDesignSystemAssetsUncachedindex.ts:656)先读内置根,只有当 tokensCssfixtureHtml 同时齐备才早退;否则再读用户安装根,逐字段 ?? 补齐。

另有 digestDesignSystemContextindex.ts:566)把 body/usage/tokens/manifest/fixture/pullIndex/importMode 七项拼成一个 sha256——用来判断"这一轮注入的设计系统上下文和上一轮是不是同一份"。

readDesignSystemAssetsindex.ts:451)里有个易漏的三元:

manifest?.files.components === undefined && manifest !== null
? Promise.resolve(undefined) // 有 manifest 但没声明 components → 不猜
: readFileOptional(path.join(brandRoot, manifest?.files.components ?? 'components.html'))

翻译成人话:没有 manifest 的老目录默认去找 components.html有 manifest 却没声明 components 的,就是明确表态"我没有夹具",不要瞎猜。

4.4 token 契约:把散文核对成 56 个格子

这是设计系统这块工程含量最高的一支。

它要解决的小问题。 DESIGN.md 里写的是"Rausch coral-pink #ff385c,用于主 CTA"这种人话。agent 拿到这段话生成页面时,得知道 --accent 到底填什么。而反过来,导入一个陌生仓库时,你只有一堆 CSS 变量,得知道哪个该当 --accent

思路。 定一份固定 56 项的 schema(packages/contracts/src/design-systems/token-schema.ts:101TOKEN_SCHEMA),每一项属于四层之一,层决定"缺了怎么办"。

下表前四行就是 TokenLayertoken-schema.ts:69)的全部四个成员;第五行 C-extension 不是 schema 里的层,它只是模块头注释(token-schema.ts:33:50)里描述的一个概念——列在这里是为了把"品牌私有 token 怎么办"这条规矩说完整:

谁决定值缺失时
A1-identity品牌本身(--bg --fg --accent 字体栈)无法替代,必须有源证据
A1-structure品牌的结构决策(字号阶、栅格、章节节奏)跨品牌无合理默认,各写各的
A2有全局合理默认允许由 _schema/defaults.css 内联兜底
B-slot可选精细档位允许 var() 别名到同族兄弟,保证组件永远解析得到
C-extension(仅注释概念,非 TokenLayer 成员)品牌私有走白名单,通用组件禁止引用

token-schema.ts:38-48 解释了为什么 A2 是"必需但有兜底"而不是"可选":agent 是把某一个品牌的 :root整块粘进单个 <style> 的,运行时没有全局默认样式表的层叠。少一个 var() 目标,transition: var(--motion-fast) 就变成 transition: ,整条规则被丢弃。

C-extension 之所以只活在注释里,是因为它的执行机制不在 TokenLayer 上而在允许清单上:token-schema.ts:50-57 说明,品牌私有名要么进白名单接受一次显式评审,要么在 ≥2 个品牌都需要时被提升成 B-slot(再有全局默认就提升成 A2),提升的动作就是把名字挪进 TOKEN_SCHEMA

绑定过程bindSchemaTokentoken-contract.ts:210)是四级降级,命中即停:

源 token 集合(从磁盘扫出来的 CSS 变量)

├─▶ ① 同名精确匹配 ──────────▶ confidence: high

├─▶ ② 角色/名字启发式 ───────▶ confidence: medium
│ (ROLE_HINTS:--accent ← accent|primary|brand,且值须过 isColorValue)

├─▶ ③ B-slot 有 aliasTo ─────▶ confidence: alias

└─▶ ④ schema fallback ────────▶ A2 层记 fallback,其余记 low

评分buildReporttoken-contract.ts:271)刻意把权重压在 A1 上:

score = round( (A1覆盖率×0.7 + 非兜底比×0.2 + 非别名比×0.1) × 100 )
grade = ≥80 excellent | ≥60 usable | ≥40 needs-review | 其余 needs-rebuild

自检validateDesignTokenOutputstoken-contract.ts:171)是这套里最见功力的一段,四条硬检查加一条软警告:

检查级别逻辑
每个 schema token 都在 tokens.css 里声明了吗error缺一个报一条
tokens.css 有没有非 schema 的野 tokenerror反向也管
每个值里的 var(--x) 引用是否都已声明error断链检测
components.html 里的 var() 是否都已声明error夹具与 token 不许脱节
夹具里 var(--accent) 出现超过 2 次warning强调色滥用是"AI 味"的典型症状

最后那条尤其巧:它把一条审美纪律(强调色一屏最多用两处)编码成了一条可自动执行的 lint。注意它先剥掉 :root{} 块再数,免得把定义本身算成使用。

证据从哪来。 token-evidence.ts 是一组正则扫描器:extractCssCustomProperties:78)抓 --x: y; 并记行号,createDesignTokenEvidenceCollector:43)把颜色(hex/rgba/hsla)、字体、间距、圆角、阴影分门别类收进五个 Map,JS/TS 文件还额外扫 Tailwind 里的 '#xxxxxx' 字面量。行号最终会进 sources: ["src/theme.css:42"],所以每一个 token 都能追回源文件的某一行。

重建决策。 prepareDesignTokenContractRebuildtoken-contract-rebuild.ts:51)读已有报告,看 score/grade/selfCheck.ok,并特别盯住四个关键 A1(KEY_A1_TOKENS = {--bg, --surface, --fg, --accent}:47)里有没有 low/fallback/alias 这类弱绑定(WEAK_CONFIDENCE:48)。判定该重建时,它不直接改文件,而是生成一份待审修订revision),走人审通道。

一个诚实的观察。 仓库内置品牌的 source/token-contract.report.json 字段形状和 token-contract.ts 生成的不完全一致——airbnb 那份用 layerCountssourceScope,而导入器产出的是 layersselfCheck。也就是说内置资产不是由这条导入路径产出的。summarizeTokenContractReportindex.ts:1068)逐字段做 typeof 判断而非整体解析,正好容下这种形状差异。

4.5 三条导入路径

入口函数干什么
本地目录importLocalDesignSystemProjectimport.ts:119扫源仓库 → 建契约 → 写出完整包
GitHubimportGitHubDesignSystemProjectgithub-import.ts:35浅克隆到临时目录,然后转交给本地路径
shadcn registryimportShadcnDesignSystemProjectshadcn-import.ts:139抓 registry item JSON,把 cssVars 铺成源 CSS,再转交

本地路径是主干,一次导入按顺序做这些事(import.ts:119-191):

源仓库
│ scanProject():读 package.json / README / 走文件树
│ ├─ 样式文件取前 80 个 → readCssVariables → 前 80 个 CSS 变量
│ ├─ tailwind 信号、assets、fonts、组件信号

buildDesignTokenContract({ sourceTokens: scan.cssVariables })
│ → bindings + tokensCss + 初次 selfCheck

renderComponentsHtml(tokensCss) ← 夹具由 token 生成,天然自洽

buildReportWithSelfCheck(report, validateDesignTokenOutputs({tokensCss, fixtureHtml}))
│ ← 加上夹具后重跑自检,这才是最终报告

写出 8 个基础文件 + preview/*.html + source/* + 拷 assets/fonts

注意顺序:先出 tokens,再用 tokens 生成夹具,最后带着夹具重跑自检。夹具不是手写的,所以"夹具引用了未声明 token"这条 error 在导入路径上永远不该触发——它是给手写品牌兜底的。

shadcn-import.ts 里有一段值得单独看:assertFetchableUrl:259)+ classifyHost/classifyIpv4/classifyIpv6:278/:297/:314)是一套 SSRF 防护——用户给的 registry URL 会被分类,私有网段/回环地址直接拒。withFetchBudget:810)再给整个抓取过程套预算上限。

4.6 渲染与其余零件

模块入口符号一句话
preview.tsrenderDesignSystemPreview:18从 DESIGN.md 里抠色板 + 字体,塞进一张固定模板;下半截把 DESIGN.md 当散文渲染(自带简易 Markdown 渲染器,含表格)
showcase.tsrenderDesignSystemShowcase:16更进一步:拿同样的 token 拼出一整张产品营销页(nav/hero/feature/pricing/FAQ/footer),让人在生成任何东西之前先"看见"这套系统
swift-colors.tsextractSwiftColors:112SwiftUI 仓库把色板写在源码里(Color(hue:saturation:brightness:)),这里把它们算成 hex;注意是 HSB 不是 HSL
source-context.tscollectDesignSystemSourceContext:51用户给了 GitHub 链接时,抓最多 3 个仓库的 description/topics/README 摘要(720 字符、3.5 秒超时)当生成上下文
generation-jobs.tscreateDesignSystemGenerationJobStore:117三类作业(生成 / 修订 / token 契约重建)各有一串固定步骤,进度 = 已成功步数比例

showcase.ts 的模块头注释直说它和 preview.ts 共用的解析工具是内联复制而非 import——为了让两个视图各自演化。这是一次有意的重复。

preview.tsshowcase.ts 的解析都"刻意宽容":导入进来的系统章节命名与项目符号风格千差万别,所以用松正则 + 找不到就退回合理默认。

4.7 两条读取路由的白名单

设计系统目录里有品牌资产、字体、源码片段,直接开放读会变成任意文件读。daemon 用两套白名单收口:

路由白名单构造允许什么
pull(给 agent 按需拉取)buildDesignSystemPullFileAllowlistindex.ts:843manifest 声明的 preview pages、sourceFiles、派生 token 文件;assets/ 递归展开;snippets 索引里以 source/snippets/ 开头的条目
static(浏览器直接取)isAllowedDesignSystemStaticFileindex.ts:886上面那些 + DESIGN.md/tokens.css/components.html/USAGE.md,再并上一张硬编码的 system/ 清单

DESIGN_SYSTEM_STATIC_SYSTEM_FILESindex.ts:873)是那张硬编码清单——system/index.htmlsystem/kit.htmlsystem/kit.dark.htmlsystem/tokens.default.json、以及 system/artifacts/ 下的 landing/deck/poster/email/newsletter/form 六张。这些文件不在 manifest 里声明,因为它们是约定俗成的固定名

addSnippetIndexEntriesindex.ts:975)末尾的 catch 注释是一句很好的安全思维:一个格式坏掉的 snippets 索引不该扩大白名单。解析失败就什么都不加,而不是放行全部。

白名单之外还有一层:两条路由都在 path.resolve 之后再确认结果落在 brandRoot 内(index.ts:494-498:534-538),白名单和路径前缀检查互为纵深。


5. 插件:纯 TS 内核 + daemon 的手脚

这节讲第三类资产。它和前两类的根本差别是:skill 和设计系统是内容,插件是内容 + 一份行为声明(要跑哪几个阶段、要什么权限、要连哪些外部服务)。

5.1 两层切分:为什么内核不碰文件系统

┌───────────────────────────────────────────────────────┐
│ packages/plugin-runtime/src —— 纯 TS,无 node:fs │
│ parsers/{frontmatter,manifest,marketplace}.ts │
│ adapters/{agent-skill,claude-plugin}.ts │
│ merge.ts resolve.ts validate.ts │
│ digest.ts pipeline-fallback.ts │
└───────────────────────────────────────────────────────┘
▲ ▲ ▲
注入 loader 注入 registry view 注入 scenarios
│ │ │
┌──────────┴──────────┐ ┌──────┴───────┐ ┌────────┴────────┐
│ apps/daemon/plugins │ │ web 预览沙箱 │ │ CI / 单测 │
│ 读盘、写 SQLite、 │ │ 传 mock │ │ 传空 registry │
│ 装包、发事件 │ │ │ │ │
└─────────────────────┘ └──────────────┘ └──────────────────┘

packages/plugin-runtime/package.json 的 description 把这条纪律写死了:"No node:fs imports — daemon/web/CI inject loaders." 实际 grep 全目录,唯一的 node 内置 import 是 digest.ts:1node:crypto,而且那行紧跟着的注释就承认了这一点并说明浏览器消费方需要 shim。

这样切的回报resolveContextresolve.ts:61)、resolveAppliedPipelinepipeline-fallback.ts:34)、applyPlugin(daemon 侧但同样是纯函数,apply.ts:91)都可以在测试里毫秒级跑完,不需要临时目录、不需要 SQLite。

5.2 三种清单源,一次合并

一个插件文件夹可以同时携带三种描述文件。resolvePluginFolderregistry.ts:97)挨个读,然后交给 mergeManifests

文件处理优先级
open-design.jsonparseManifest(Zod,passthrough 保留未知字段)最高(sidecar 永远赢)
SKILL.mdadaptAgentSkilladapters/agent-skill.ts:36次之
.claude-plugin/plugin.jsonadaptClaudePlugin再次

mergeManifestsmerge.ts:17)的规则只有两条,但都不显然:

  • 深合并 + 高优先层锁定deepMerge 里,已存在的标量/数组/null 绝不被低优先层覆盖(merge.ts:88-95 的空 else 分支写着注释 "Sidecar wins")。
  • compat.* 是唯一做并集的字段mergeCompatmerge.ts:41):按 path 去重,sidecar 顺序在前。外来内容进 compat 列表而不是被丢掉。

合并完立刻 validateSafe,不过就整份失败(registry.ts:150-155)。最后 id 取 manifest.name 小写,不取文件夹名registry.ts:163-165:plugin id IS the manifest name)。

5.3 校验:schema 管不了的那几条

validateSafevalidate.ts:43)补的是 JSON Schema 表达不了的跨字段规则:

规则级别为什么
repeat: true 的阶段必须有 untilerror否则就是无限循环
能力名不在 v1 词表里warning让未来的 spec 补丁能引入新能力而不炸掉已有安装
oauth.route='connector' 引用的 connectorId 必须在 od.connectors 里声明过error悬空引用
oauth.route='mcp' 的 mcpServerId 必须在 od.context.mcperror同上

"未知能力只警告"是个刻意的取舍:校验的严格度按可演化性分级——语法错误是 error,词表滞后是 warning。

until 表达式本身有独立的微型解析器(until.ts)。它是封闭词表而非任意 JS:只认 critique.score / iterations / user.confirmed / preview.ok / build.passing / tests.passing 六个信号,比较符 == != >= <= > <,用 &&||/, 组合。理由写在 until.ts:13-15:小到 od plugin doctor 能在安装时做语法检查而不必启动解释器,而 daemon 拒绝执行 until 解析不了的阶段。

5.4 流水线:原子、阶段、devloop

原子目录是一张静态表(FIRST_PARTY_ATOMSatoms.ts:16),每条带 status: 'implemented' | 'planned'。这个二元状态让 doctor 可以警告而不拒绝引用了尚未实现原子的插件(atoms.ts:1-4)。表里现在共 22 个原子(atoms.ts:16-42,从 discovery-question-formhandoff,文件在 apps/daemon/src/plugins/atoms.ts),覆盖四种 taskKind;截至本 commit 22 条全是 implementedplanned 这一档目前是空的。

阶段调度runPipelinepipeline.ts:92)驱动,它是"无头"的——真正干活的 StageRunner 由调用方注入:

for stage of pipeline.stages:
iteration = 0
while iteration < max: ← max = repeat ? env.maxIterations : 1
emit(pipeline_stage_started)
outcome = await runStage({stage, iteration, snapshot}) ← 注入的 worker
signals = { ...outcome.signals, iterations: iteration+1 }
recordIteration(db, …) ← 写 run_devloop_iterations,本模块是唯一写者
converged = evaluateUntil(expr, signals)
emit(pipeline_stage_completed)
if converged: break

OD_MAX_DEVLOOP_ITERATIONS(默认 10)的上限强制在这里,注释直说目的:一个写坏的插件不能烧光额度pipeline.ts:6-8)。

pipeline-runner.tsrunPipelineForRun:55)是把这台纯调度器接到实际 run 上的薄壳:转发 SSE 事件、在阶段开始前抬起对应的 GenUI 表单、把自动派生的 __auto_connector_* OAuth 提示走跨会话缓存(同项目第二个会话不再重播)。

5.5 ensureCoreQualityStages:不许绕过质量闭环

它要解决的小问题。 模板类插件常常只声明一个 generate 阶段——它有锁定的参考种子,不想重跑 discovery。但 resolveAppliedPipeline 会把这份声明原样返回并替换掉核心场景流水线,于是 plan(TodoWrite)和 critique(五维反 AI 味)两个阶段直接消失。仓库里记录的现场症状是:"用了插件就跳过五阶段主流程,没 todolist,没有真正的 anti-slop"(ensure-core-stages.ts:12-15)。

修法ensure-core-stages.ts:87):

输入 pipeline
├─ taskKind ≠ new-generation ────────────▶ 原样返回(迁移/创作流各有自己的阶段契约)
├─ mode ∈ {image,video,audio} ───────────▶ 媒体保持 generate-only
│ └─ 若它是 scenario 回落来的,还要塌回只剩 generate 阶段
├─ 找不到含 file-write/live-artifact 的 generate 阶段 ─▶ 原样返回
└─ 否则:在该 generate 阶段【前】插 plan、【后】插 critique

两个细节体现了功力:

  • 注入位置是紧贴 generate 前后,不是追加到末尾。因为 runner 严格按声明序执行,generate → handoff 这种流水线如果把 critique 追加到最后,下游阶段就会在质量闭环跑完之前把产物发出去了(:127-132)。
  • 媒体判定看 mode 而不是看原子形状。内置的 image-poster/audio-jingle 也是 generate: [file-write, live-artifact](它们要种一份 example.html),原子形状和代码产物一模一样,只有 manifest 的 mode 是可靠信号(:20-33)。

注入的 plan 只带 todo-write不带 direction-picker——模板的方向已经被参考种子钉死了,再问一遍方向是打扰用户(:34-37)。

5.6 快照钉住:run 只认那一张纸

这是插件这块的核心不变量:AppliedPluginSnapshot 是"插件"与"run"之间唯一的契约resolve-snapshot.ts:5-6 引 spec §8.2.1 invariant I3)。

InstalledPluginRecord(活的,会被升级覆盖)

│ applyPlugin() ← 纯函数:校验输入 → 解析 context → 算 digest
│ → 解析 pipeline → 补质量阶段 → 派生 GenUI 表单

ApplyComputed(内存)

│ createSnapshot() ← 唯一允许写 applied_plugin_snapshots 的模块

┌────────────────────────────────────────────────────┐
│ applied_plugin_snapshots 一行 │
│ manifest_source_digest ← 冻结算法算出的哈希 │
│ resolved_context_json ← 当时解析到的 skill/DS/craft │
│ pipeline_json ← 补齐后的有效流水线 │
│ capabilities_granted/required │
│ status: fresh | stale expires_at: TTL 或 NULL │
└────────────────────────────────────────────────────┘

├─ linkSnapshotToRun() → expires_at = NULL(已被引用,GC 不碰)
└─ markSnapshotStale() → 插件升级后 doctor 翻 status

四个设计决定:

① digest 算法被冻结。 manifestSourceDigestdigest.ts:29)把 {manifest, inputs, resolvedContextRefs} 按键名字典序递归排序后 JSON.stringify 再 sha256。digest.ts:9-11 写明:输入形状永远稳定,改它必须同步更新 CI fixture,否则历史快照会悄悄漂移。resolvedContextRefs 归一成 {kind, ref} 对,所以两个插件解析到同一批 id 就产生同一个 digest,与展示用的 label 无关。

② 未被引用的快照有 TTL。 createSnapshotsnapshots.ts:66-76)在没有 runId 时按 snapshotUnreferencedTtlDays 盖过期时间;一旦 linkSnapshotToRun:150)把 run 挂上,expires_atNULL,GC 永不回收。

③ 变陈旧不重写。 markSnapshotStale:238)只翻 status,从不重写 resolved_context_json。模块头注释一句话概括取舍:"历史可复现性胜过新鲜度"snapshots.ts:12-13)。

④ 写入口收敛到一个模块。 snapshots.ts:1-3 声明这是唯一允许对该表 INSERT/UPDATE 的模块,且 apply 流水线永远不许直接碰表。

diffSnapshotssnapshot-diff.ts:44)是配套的排查工具:比两份快照,首先给一个 digestEqual 布尔——用来一眼验证"同一个插件应用两次产出字节相同的 digest"这条重放不变量。

5.7 信任、能力、锁

两档信任trust.ts):

sourceKind默认 trust默认能力
local(用户自己拷进来的文件夹)trustedprompt:inject, mcp:*, connector:*, genui:*, pipeline:*
其余(bundled / marketplace / github / url / project)restrictedprompt:inject

defaultTrustForRecordtrust.ts:25)就是这一行判断。registry.ts:167-172 有一段注释记录了一次修复:这里以前硬编码成 restricted,导致本地的 scenario 插件拿不到跑自己流水线所需的 pipeline:*

能力不足时resolvePluginSnapshotresolve-snapshot.ts:148)短路返回 §9.1 规定的错误体,调用方映射成 HTTP 409 或 CLI 的 exit-66 stderr JSON——两个界面同一份语义。

Lockfilelockfile.ts)记录每个插件的 resolvedSource/resolvedRef/manifestDigest/archiveIntegritywritePluginLockfile:64)写之前按 name 排序,保证 lockfile 的 diff 是稳定的。

市场marketplaces.ts)在 v1 把 catalog body 当不透明 JSON存,Zod 校验留在 plugin-runtime 的 parser 里。新加的用户市场默认 restricted(只能发现,不能自动信任),除非显式 --trustmarketplaces.ts:12-14)。

安装约束installer.ts:9-16)是一串硬上限:拒绝路径穿越段、拒绝符号链接、默认 50 MiB 树大小上限、拒绝在目标位置覆盖另一个 id 的插件;tarball 解压走 tar 的 strict 模式继承同样的限制。

内置插件bundled.ts)开机时扫 plugins/_official/**,以 source_kind='bundled' 注册。它们的 fs_path 留在仓库里而不进用户安装根——所以 daemon 升级时它们与代码同步轮换(bundled.ts:9-12)。仓库里现有 13 个官方 scenario,外加 atoms / design-systems / examples / image-templates / video-templates 五组。

5.8 作者工具链与"把一次成功 run 提炼成插件"

命令实现一句话
od plugin validate <folder>validatePluginFoldervalidate.ts:63装之前先 lint:走同一个 resolvePluginFolder,所以清单解析与安装后逐字节一致
od plugin doctor <id>doctorPlugindoctor.ts:43装之后体检:清单 + 原子 + 引用绑定 + digest 漂移(顺手翻 stale)
od plugin verifyverifyPluginverify.ts:84CI 友好聚合:doctor + simulate(until 收敛干跑)+ canon(对拍固定夹具)
od plugin pack <folder>packPluginpack.ts:67打 tgz,与安装器的 HTTPS-tarball 路径同形;排除 node_modules/.git/dist,不追符号链接
od plugin publish --tobuildPublishLinkpublish.ts:101纯函数,生成目标 catalog 的提交深链,从不直接改 catalog

最有意思的是 skill-candidates.ts——把一次成功的 run 自动提炼成候选插件

一次成功的 run 结束
│ detectSkillPluginCandidate(:40)
│ ├─ 从助手消息里扒引用到的 .md 路径 → 读文件内容
│ ├─ 从附件列表里补
│ └─ 扒出仓库/插件样 URL
│ 过滤 isEligibleSourceRef,算指纹(内容 sha256 串联)

候选行(skill_plugin_candidates,指纹去重)
│ 用户点"生成草稿" → generateSkillPluginDraft(:155)

<projectRoot>/plugin-source/<slug>/
├── SKILL.md ← synthesizeSkill 合成
├── open-design.json ← buildManifest 合成
└── references/
├── provenance.json ← 候选来源、runId、conversationId 全留痕
└── source-N-<basename>.md ← 原始素材副本(≤96 KB/份)


立刻 validatePluginFolder() 自检,诊断连同草稿一起返回

置信度是启发式的:来源路径以 SKILL.md 结尾给 0.95,否则 0.78(skill-candidates.ts:86)。provenance.json 里刻意把 content 置空(:177)——溯源信息留下,正文副本单独放在 references/source-*.md,避免同一份内容存两遍。

闭环在这里合上了:草稿一落盘就跑作者侧校验,用的还是那个 validatePluginFolder。产品把"你刚做完的这件事,要不要变成一个可复用的插件?"变成了一次点击。


6. 插件契约本体:open-design.json v1

plugins/spec/SPEC.md 是给贡献者和外部编码 agent 看的紧凑契约。它的层级设计很清楚:

最小插件 = 一个目录 + 一份带 name/description frontmatter 的 SKILL.md 这一层刻意与通用 agent skill 生态同形,所以同一个文件夹既能被 Open Design 装,也能被别的 skill runner 装(SPEC.md §1)。

增强插件 = 再加一份 open-design.json 它才带来市场卡片、流水线、输入表单、能力申请(SPEC.md §2)。

manifest 的主要分区:

分区内容备注
specVersion遵循的 spec 版本(当前 1.0.0与插件自身 version 相互独立
compat.agentSkills[].path指回 ./SKILL.md让 daemon 只凭清单就能重新定位技能正文
od.kind / od.taskKind / od.mode / od.scenario分类四元组taskKind 只有四个合法值
od.pipeline.stages[]{id, atoms[], repeat?, until?}重复阶段必须有 until(硬约束)
od.inputs[]应用时的简单取值缺必填项 → MissingInputError → 422
od.genui.surfaces[]运行中需要人介入时的受控输入form/choice/confirmation/oauth-prompt;持久化范围 run/conversation/project
od.capabilities[]权限申请restricted 安装默认只有 prompt:inject
od.connectors需要/可选的外部连接器与 GenUI 的 oauth 路由交叉校验
od.context引用哪些 skill / 设计系统 / craft / 资产 / MCP / 原子resolveContext 绑定到真实注册表

内置的 od-new-generation 是一份标准样本(plugins/_official/scenarios/od-new-generation/open-design.json),它的流水线就是全站默认的四阶段:

"pipeline": { "stages": [
{ "id": "discovery", "atoms": ["discovery-question-form"] },
{ "id": "plan", "atoms": ["direction-picker", "todo-write"] },
{ "id": "generate", "atoms": ["file-write", "live-artifact"] },
{ "id": "critique", "atoms": ["critique-theater"],
"repeat": true, "until": "critique.score>=4 || iterations>=3" }
] }

场景回落。 当一个普通插件没写 od.pipeline 时,resolveAppliedPipelinepipeline-fallback.ts:34)按 taskKind 去 bundled scenario 列表里找同类的流水线抄过来,并在结果里记 source: 'scenario'scenarioId,这样审计日志能回答"这次 run 为什么从阶段 X 开始"。scenario 插件自己永不回落(:41)——那是恒等情形。

od.capabilities 的 v1 词表:prompt:injectfs:readfs:writemcpsubprocessbashnetworkconnectorconnector:<id>(SPEC.md §6)。


7. 巧妙之处(可借鉴)

① 用"多根 + 第一个赢"实现覆盖,而不是删原件。 一行 rootIdx === 0 ? "user" : "built-in"skills.ts:242)同时给出了覆盖语义和来源标签,UI 据此渲染来源徽标并只允许删用户侧条目。内置资源目录可以是只读的。

② 缓存 key 不是 id,是内容指纹。 designSystemAssetsCacheFingerprintindex.ts:688)把候选文件的 size/mtime/存在性哈希进 key。改文件 → 换 key → 自动失效。省掉了 watcher 与失效通知这一整套复杂度。

③ 把审美纪律编译成 lint。 "强调色一屏最多两处"这条设计口诀,被 validateDesignTokenOutputs 变成数 var(--accent) 出现次数的一条 warning(token-contract.ts:194-196),而且先剥掉 :root{} 定义块再数。

④ 校验严格度按可演化性分级。 未知能力名只警告(validate.ts:58-62),因为 spec 会往前走;repeatuntil 直接报错,因为那是死循环。规则的等级和"它多久会过时"挂钩。

⑤ 让被质疑的东西变成待审修订,而不是自动改。 prepareDesignTokenContractRebuild 判定该重建时,产出的是一份 revision(feedback + baseBody + proposedBody + fileChanges)走人审通道,不直接落盘。

⑥ 让 agent 自己选路径。 side-file 前言把"相对路径优先、不行再用绝对路径"的判断规则写进 prompt 给模型读(skills.ts:570-590),而不是 daemon 侧按 CLI 名做特性探测。规则可读、可改、不用发版。

⑦ 快照永不重写。 插件升级只把旧快照标 staleresolved_context_json 原封不动。旧 run 的可复现性优先于新鲜度。

⑧ 纯核 + 注入边界,把可测性变成架构属性。 plugin-runtime 全目录只有一处 node:cryptoapplyPlugin 是同步纯函数。所有 fs / SQLite / 网络都被推到 daemon 侧薄壳里。

⑨ 校验用同一条解析路径。 validatePluginFolder 复用 resolvePluginFolder,注释直说目的:"so manifest parsing is byte-equal"validate.ts:9-11)。安装前的 lint 与安装后的行为不会出现"lint 过了装不上"。


8. 边界与局限

frontmatter 解析器只覆盖 YAML 的一个子集。 frontmatter.ts 头一行注释自己写着:支持标量、块字面量(|)、扁平数组和一层嵌套对象;要真 YAML(锚点、flow 风格、复杂嵌套)请换 js-yaml。目前它服务着 157 + 112 + 150 份文件,属于刻意的取舍。

发现流程完全静默。 listSkills 的整个循环体包在一个空 catch 里(skills.ts:413-415)。一个 frontmatter 写坏的 skill 不会报错,只是不出现在列表里。作者拿不到任何反馈信号。

没有文件监听。 模块头注释直说:每次 GET /api/skills 重扫一遍,"dozens of skills 规模下没问题"(skills.ts:1-3)。当前是 157 + 112 = 269 个目录,已经超出"dozens"一个量级。

listSkills 里没有并发上限。 每个条目串行 stat + readFile,派生样例扫描又是一次 readdir

源码注释与实际目录已经脱节。 skills.ts:832 / :1029 还写着 user-skills,实际根是 <dataDir>/skillsserver.ts:834)。没有任何机制强制注释与常量同步,读源码时容易被带偏(§3.6 展开)。

内置资产与导入器产物形状不完全一致。 内置品牌的 token-contract.report.jsonlayerCounts/sourceScope,导入器产出 layers/selfChecksummarizeTokenContractReport 逐字段防御性读取才吞下了这个差异——说明内置资产走的是另一条(本仓库里看不全的)生成路径。

多语言 DESIGN-*.md 没有读取路径。 150 套系统 × 最多 17 种语言的译本躺在磁盘上,但 daemon 源码里搜不到任何读它们的地方,静态白名单也不放行。

导入扫描有硬上限。 scanProject 只取前 80 个样式文件、前 80 个 CSS 变量(import.ts:209-214)。大仓库的 token 可能被截断。

原子目录是静态数组。 FIRST_PARTY_ATOMS 硬编码在 atoms.ts:16,第三方无法注册新原子——插件只能引用这 22 个一方原子,或者引用一个"未来会有"的名字换一条警告。

C-extension 只有概念没有类型。 TokenLayertoken-schema.ts:69)只有四个成员,品牌私有 token 的约束靠外部白名单执行,schema 本身表达不了它。

市场 catalog 在 v1 是不透明 JSON。 marketplaces.ts:10-12 承认这一点:真正的 schema 校验在 plugin-runtime 的 parser 里,这个模块只存 parser 返回的东西。


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

主题文件路径符号名
skill 扫描与多根影子apps/daemon/src/skills.tslistSkills, SkillSource, SkillInfo
skill id 别名转发apps/daemon/src/skills.tsSKILL_ID_ALIASES, resolveSkillId, findSkillById
派生样例卡片apps/daemon/src/skills.tscollectDerivedExamples, splitDerivedSkillId, resolveDerivedExamplePath, isSafeExampleKey
skill 正文前言注入apps/daemon/src/skills.tswithSkillRootPreamble, collectReferencedSideFiles, dirHasAttachments
frontmatter 归一化apps/daemon/src/skills.tsnormalizeCritiquePolicy, normalizeMode, normalizeCategory, normalizeCraftRequires, derivePrompt
用户 skill 读写apps/daemon/src/skills.tsimportUserSkill, updateUserSkill, cloneSkillSideFiles, deleteUserSkill, listSkillFiles, slugifySkillName
用户 skill 路由接线apps/daemon/src/routes/static-resource.tsPOST /api/skills/import:227)、skill 更新(:266
根目录常量apps/daemon/src/server.tsUSER_SKILLS_DIR:834), SKILL_ROOTS, DESIGN_TEMPLATE_ROOTS, ALL_SKILL_LIKE_ROOTS, DESIGN_SYSTEMS_DIR
YAML 子集解析器apps/daemon/src/design-systems/frontmatter.tsparseFrontmatter, parseYamlSubset, coerce
设计系统列举与合并apps/daemon/src/design-systems/index.tslistDesignSystems, pickFinalSwatchRow, LEGACY_DESIGN_SYSTEM_ARTIFACTS
资产读取与指纹缓存apps/daemon/src/design-systems/index.tsresolveDesignSystemAssets, readDesignSystemAssets, designSystemAssetsCacheFingerprint, digestDesignSystemContext
读取白名单apps/daemon/src/design-systems/index.tsbuildDesignSystemPullFileAllowlist, isAllowedDesignSystemStaticFile, DESIGN_SYSTEM_STATIC_SYSTEM_FILES, addSnippetIndexEntries
打包与文档生成apps/daemon/src/design-systems/index.tsbuildDesignSystemSkillsMarkdown, buildUserDesignSystemArchive, ensureGeneratedDesignSystemFiles
token 层级契约packages/contracts/src/design-systems/token-schema.tsTOKEN_SCHEMA:101), TokenLayer:69), TokenSpec
token 绑定与打分apps/daemon/src/design-systems/token-contract.tsbuildDesignTokenContract, bindSchemaToken, ROLE_HINTS, buildReport, validateDesignTokenOutputs
token 证据采集apps/daemon/src/design-systems/token-evidence.tscreateDesignTokenEvidenceCollector, extractCssCustomProperties, lineNumberAt
token 契约重建决策apps/daemon/src/design-systems/token-contract-rebuild.tsprepareDesignTokenContractRebuild, KEY_A1_TOKENS, WEAK_CONFIDENCE
三条导入路径apps/daemon/src/design-systems/{import,github-import,shadcn-import}.tsimportLocalDesignSystemProject, importGitHubDesignSystemProject, importShadcnDesignSystemProject, assertFetchableUrl
渲染视图apps/daemon/src/design-systems/{preview,showcase}.tsrenderDesignSystemPreview, renderDesignSystemShowcase
SwiftUI 色值提取apps/daemon/src/design-systems/swift-colors.tsextractSwiftColors, hsbToHex, evalSwiftNumber
GitHub 来源上下文apps/daemon/src/design-systems/source-context.tscollectDesignSystemSourceContext, mergeSourceContextIntoInput
生成作业进度apps/daemon/src/design-systems/generation-jobs.tscreateDesignSystemGenerationJobStore, STEP_DEFS, TOKEN_CONTRACT_REBUILD_STEP_DEFS
插件纯核入口packages/plugin-runtime/src/index.ts(re-export 全部)
清单解析与适配packages/plugin-runtime/src/{parsers,adapters}/parseManifest, adaptAgentSkill, adaptClaudePlugin
清单合并packages/plugin-runtime/src/merge.tsmergeManifests, mergeCompat, deepMerge
引用解析packages/plugin-runtime/src/resolve.tsresolveContext, RegistryView, ScenarioRegistryEntry
冻结 digestpackages/plugin-runtime/src/digest.tsmanifestSourceDigest, canonicalize
跨字段校验packages/plugin-runtime/src/validate.tsvalidateManifest, validateSafe, KNOWN_CAPABILITIES
流水线回落packages/plugin-runtime/src/pipeline-fallback.tsresolveAppliedPipeline
插件目录解析apps/daemon/src/plugins/registry.tsresolvePluginFolder, registryRootsForDataDir, upsertInstalledPlugin
安装与卸载apps/daemon/src/plugins/installer.tsinstallPlugin, installFromLocalFolder, uninstallPlugin
内置插件登记apps/daemon/src/plugins/bundled.tsRegisterBundledPluginsInput
市场与锁apps/daemon/src/plugins/{marketplaces,lockfile}.tsaddMarketplace, refreshMarketplace, resolvePluginInMarketplaces, upsertPluginLockfileEntry
信任与能力apps/daemon/src/plugins/trust.tsdefaultTrustForRecord, resolveCapabilitiesGranted, TRUSTED_DEFAULT_CAPABILITIES
应用(纯函数)apps/daemon/src/plugins/apply.tsapplyPlugin, MissingInputError, pickFirstLocalSkillPath
质量阶段兜底apps/daemon/src/plugins/ensure-core-stages.tsensureCoreQualityStages, DESIGN_ARTIFACT_ATOMS, MEDIA_MODES
原子目录apps/daemon/src/plugins/atoms.tsFIRST_PARTY_ATOMS:16), isImplementedAtom
调度与 devloopapps/daemon/src/plugins/{pipeline,pipeline-runner,until}.tsrunPipeline, StageRunner, PipelineEventSink, runPipelineForRun, isParseableUntil
快照钉住apps/daemon/src/plugins/{snapshots,resolve-snapshot,snapshot-diff}.tscreateSnapshot, linkSnapshotToRun, markSnapshotStale, resolvePluginSnapshot, diffSnapshots
体检与作者工具apps/daemon/src/plugins/{doctor,validate,verify,pack,publish}.tsdoctorPlugin, validatePluginFolder, verifyPlugin, packPlugin, buildPublishLink
run → 候选插件apps/daemon/src/plugins/skill-candidates.tsdetectSkillPluginCandidate, generateSkillPluginDraft
插件契约文本plugins/spec/SPEC.md
磁盘样本skills/apple-hig/, design-systems/airbnb/, plugins/_official/scenarios/od-new-generation/

接着读: 这些正文最终怎么被拼进一份 system prompt,见 提示词工厂critiquePolicycritique 阶段怎么驱动第二个 agent,见 质量闭环;一次 run 从按回车到产物落盘的全景,见 run 生命周期