开源 Agent 套件 ECC 的四份 JSON Schema:约束层怎么管住一套增强件

2026-07-29

本文基于 ECC 仓库 commit 591ab5c(2026-07-29)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/affaan-m/ECC 最新代码与文档为准。

一套装在编码 Agent 之上的增强件,真正的难点不在于写多少个 agent 和技能,而在于这些东西被复制到别人机器上之后,谁能说清哪个文件是从哪来的、装了哪些模块、上次会话干了什么。ECC 的做法是把这几件事各写成一份 JSON Schema,让格式先固定下来,再让脚本去检查格式之外的关系。

站内已经有两篇讲通用方法论的文章:结构化输出为什么老是不稳讲的是让模型吐出合法结构的通用手段,Agent 输出约束该不该做成硬校验讲的是约束该放在提示词里还是放在代码里。这篇不重复那些判断,只看一个能当场打开核对的项目——ECC 是怎么把这类约束落成具体文件的,以及它在落地过程中付出了什么代价。

一、先看四份契约各自站在哪个位置

ECC 仓库根目录下有个 schemas 目录,里面躺着一组 JSON Schema 文件。本篇集中看其中四份,它们分别对应四类完全不同的数据。

组成部分它负责什么对应仓库位置你什么时候会碰到它
插件清单契约约束这个套件作为 Claude Code 插件对外声明的字段形状schemas/plugin.schema.json,实例是 .claude-plugin/plugin.json改插件元信息、发布或安装失败时
技能溯源契约规定学习得到和外部导入的技能必须带哪些出处字段schemas/provenance.schema.json,实现在 scripts/lib/skill-evolution/provenance.js系统自动生成技能、你从外部拷技能进来时
状态存储契约定义会话、技能运行、决策等七类实体的字段schemas/state-store.schema.json,实现在 scripts/lib/state-store/想查历史会话、想统计技能效果时
安装模块契约描述套件由哪些模块组成、每个模块往哪些 harness 装schemas/install-modules.schema.json,实例是 manifests/install-modules.json新增技能或命令、做选择性安装时

这四份的共同点是:都不是给模型看的,是给人和 CI 看的。模型输出的稳定性问题在前面提到的两篇里已经讲过;这一层解决的是另一件事——套件自身的元数据不能靠口头约定。

schemas 目录里还有 hooks、memory、package-manager、install-profiles、install-state 等几份 schema,本篇不逐个展开,但后面讲安装那一节会带到 profiles 和 install-state,因为它们和模块清单是配套的。

二、插件清单:你的 schema 不是最终裁判

plugin.schema.json 用的是 draft-07,标题写着 Claude Plugin Configuration。它只把 name 列为必填,其余字段包括 versiondescriptionauthorhomepagerepositorylicensekeywordsskillscommandsmcpServersfeatures 都是可选。authoroneOf 允许写成字符串或者带 nameurl 的对象,mcpServers 更宽松,字符串、数组、对象都收。顶层写死了 additionalProperties: false

实例文件 .claude-plugin/plugin.json 里,nameecclicense 是 MIT,repository 指向 GitHub 仓库,skillscommands 各是一个只含目录路径的数组,mcpServers 是一个空对象。

有意思的地方在于 features 这个可选对象。它允许声明 agents、commands、skills 的数量,还有 configAssets 布尔值、hookEventscustomTools 两个字符串数组,并且这个子对象同样 additionalProperties: false。但实际的 plugin.json 里并没有写 features。也就是说 schema 预留了一块能力自描述的位置,实例暂时没用。

更值得琢磨的是另一件事:在整个仓库里搜 plugin.schema.json 这个文件名,搜不到任何代码引用它。CI 脚本目录 scripts/ci/ 下有 validate-agents、validate-commands、validate-hooks、validate-skills、validate-install-manifests 等一串校验器,唯独没有校验插件清单的那一个。这份 schema 更像一份参考文档,不是一道闸门。

为什么会这样,.claude-plugin/PLUGIN_SCHEMA_NOTES.md 里讲得很直白。那份文档记录的是 Claude Code 插件校验器的实际行为,其中一条写道:这个仓库过去在 plugin.json 里显式列出了 agents 字段,通过了仓库自己的 schema,却被 Claude Code 的真实校验器拒绝,报错只有一句 agents: Invalid input。文档里还有一张表,记录 hooks 字段被加上、去掉、再加上、再去掉的四次往返提交,原因是 Claude Code 在不同版本之间改了行为——文档写的是早先需要在清单里显式声明,后来改成按约定自动加载,再显式声明反而会报重复加载的错。

这件事对你的启发很实在:当你的组件要交给一个外部运行时去加载,你自己写的 schema 只能保证内部一致,最终裁判是那个运行时的校验器。要么把外部校验命令接进 CI,要么像 ECC 这样,老老实实把踩过的坑写成一份文档挂在清单文件旁边。

三、技能溯源:哪些技能必须带身份证

provenance.schema.json 是四份里最短的,四个字段全部必填:source 写出处,可以是 URL、路径或标识符;created_at 是 ISO 8601 时间戳;confidence 是 0 到 1 之间的数字;author 写是谁或什么东西产出了这个技能。和前面那份相反,它把 additionalProperties 设成了 true,留了扩展口子。

只看 schema 会漏掉最关键的一半——它管的范围。docs/SKILL-PLACEMENT-POLICY.md 把技能分成四类,仓库里 skills/ 目录下的叫 curated,随安装分发;~/.claude/skills/learned/ 下的是运行过程中学出来的;~/.claude/skills/imported/ 下的是用户从外部拿进来的;还有一类 evolved 放在 ~/.claude/homunculus/ 下面。溯源文件 .provenance.json 只对 learned 和 imported 强制要求,与 SKILL.md 同级摆放。仓库自带的 curated 技能不需要这个文件,署名走 SKILL.md frontmatter 里的 origin 字段;evolved 那类的溯源继承自它的来源,不单独要一份。

实现文件 scripts/lib/skill-evolution/provenance.js 把这套判断写成了代码:classifySkillPath 按路径归类,requiresProvenance 由类型决定要不要,readProvenance 在该有而文件不存在时直接抛出 Missing provenance metadata,writeProvenance 则反过来——如果你想往一个不属于 learned 或 imported 的目录写溯源文件,它会抛错拒绝。

这里有个细节值得你留意。这份实现里的 validateProvenance 是手写的字段检查:字符串要非空、created_at 要能被 Date.parse 解析、confidence 必须是 0 到 1 的数字。它没有加载那份 schema,也没有用 Ajv 编译。而隔壁的状态存储走的是另一条路。同一个仓库里两种做法并存,说明这类契约层往往是分批长出来的,而不是一开始就统一规划好的。你自己动手时最好一开始就定死:schema 是唯一事实来源,还是允许实现单独写一份检查。两份并存意味着有一天它们会不一致。

分类的思路本身可以直接搬:一个 Agent 系统里的能力,来源不同则信任等级不同。仓库自带的经过评审,运行中学出来的和用户拷进来的没有,那就用一个必填的出处字段把差别显式化。这和技能机制怎么用那篇讲的组织方式是互补的——那篇讲怎么写技能,这里讲的是技能多了以后怎么区分来路。

四、状态存储:把会话历史变成七张有约束的表

state-store.schema.json 带了个 $id,值是 ecc.state-store.v1。顶层是七个数组:sessionsskillRunsskillVersionsdecisionsinstallStategovernanceEventsworkItems,顶层同样 additionalProperties: false

看几个实体就能摸清它的取向。skillRun 的必填字段是 idskillIdskillVersionsessionIdtaskDescriptionoutcomefailureReasontokensUseddurationMsuserFeedbackcreatedAt。注意 failureReasontokensUseddurationMsuserFeedback 这几个明显可能没有值的字段也在必填列表里——schema 用 nullableStringnullableInteger 这类定义允许它们是 null,但不允许你干脆不写这个键。

这是个刻意的取向:可空但必填。写入方必须对每个字段表态,哪怕表的是”这次没有”。缺失和显式为空被区分开,读取方就不用猜到底是没采集到还是采集到了空值。代价是写入代码更啰嗦,每次加字段所有写入点都得跟着改。

decision 实体的字段是 idsessionIdtitlerationalealternativessupersedesstatuscreatedAt,摆明了是架构决策记录那一套:有理由、有备选、能标记被谁取代。skillVersioncontentHashamendmentReasonpromotedAtrolledBackAt,技能被改过、被提升过、被回滚过都留痕。governanceEventeventType 加自由形状的 payload,再配 resolvedAtresolution

实现在 scripts/lib/state-store/schema.js。它用 Ajv 加载那份 schema,然后按 $defs 里的定义名逐个编译出实体级 validator,对外暴露 validateEntity,验证器结果做了缓存。index.js 里能看到落盘位置:默认路径拼的是 home 目录下的 .claude/ecc/state.db,驱动用的是 sql.js,并且注释里特意提醒 db.export() 会隐式结束事务,所以磁盘写入必须推迟到事务提交之后。migrations.js 里是建表 SQL,sessions 表的 snapshot 列带了 CHECK (json_valid(snapshot)) 约束。

这里也有代价:schema 里是 taskDescription 这样的驼峰,SQL 表里是 task_description 这样的蛇形,两套命名之间必须有映射层。JSON Schema 校验的是进出这一层的对象形状,SQLite 的列约束和外键管的是落盘之后的完整性,两道关卡各管一段,谁都不能省。想清楚哪些约束该放前面哪些该放后面,可以对照参数校验该放在哪一层那篇的分层思路。

五、安装模块:schema 管形状,脚本管关系

install-modules.schema.json 是四份里约束最密的。顶层必须有 version(整数,最小 1)和 modules(至少一项),additionalProperties: false。每个模块的九个字段全部必填:idkinddescriptionpathstargetsdependenciesdefaultInstallcoststability,一个都不能省。

约束的细密程度体现在这几处:id 有正则 ^[a-z0-9-]+$,只能小写字母数字和连字符;kind 是枚举,取值限定在 rules、agents、commands、hooks、platform、orchestration、skills、docs 之内;targets 也是枚举,列的是它声明支持的一串 harness——claude、claude-project、cursor、antigravity、codex、gemini、opencode、codebuddy、joycode、qwen、zed、hermes、openclaw、kimi;cost 只能是 light、medium、heavy 之一;stability 只能是 experimental、beta、stable 之一。

看实例更直观。manifests/install-modules.json 里第一个模块 id 是 rules-core,kind 是 rules,paths 只有一项 rulesagents-core 的 paths 是 .agentsagentsAGENTS.md 三项;commands-core 的 paths 除了 commands 目录还带上了 scripts/harness-audit.jsscripts/skills-health.jshooks-runtime 的 paths 是 hooksscripts/hooksscripts/lib,它的 targets 明显比前几个短。coststability 这两个字段是给安装者做取舍用的信号:这块东西大不大、稳不稳,由维护者自己标注。

真正让这份清单可靠的不是 schema,是 scripts/ci/validate-install-manifests.js。它先用 Ajv 跑三份 schema(modules、profiles、components),过了之后才开始查 schema 表达不了的关系:

  • 模块 id 不能重复;
  • dependencies 里的每一项必须是已知模块,且不能依赖自己;
  • paths 里的每个路径必须在仓库里真实存在,脚本注释写得很硬——没有可选路径、没有生成路径的处理,缺一个就报错;
  • 同一个路径不能被两个模块同时认领,脚本用一张 claimedPaths 表检测,冲突时会把两个模块 id 一起打出来;
  • skills/ 下每个含 SKILL.md 的目录都必须被某个模块的 paths 覆盖,否则报”未被任何安装模块引用”,例外只有一个显式写死的白名单条目;
  • profile 引用的模块必须存在且不重复,coredevelopersecurityresearchfull 这几个 profile 必须齐;
  • full profile 必须包含所有模块,只有 kind 为 docs 且 defaultInstall 为 false 的可以缺席。

这套分工很清楚:JSON Schema 负责形状和枚举,一段几十行的脚本负责引用完整性、路径唯一性和覆盖率。你自己搭清单式配置时,这个边界可以直接照抄——别指望 schema 去表达”这个路径必须在磁盘上存在”。

配套的还有安装侧的运行时记录。schemas/install-state.schema.json 定义了装完之后落在目标机器上的那份状态:schemaVersion 是常量 ecc.install.v1,必填项包括 installedAttargetrequestresolutionsourceoperationstarget 里带 idrootinstallStatePath。有了这份记录,scripts/ 下的 list-installed.jsdoctor.jsrepair.js 才有据可查,install-plan.jsinstall-apply.js 的计划与执行才能对上。同一件事在状态库里也有一份 installState 实体,字段是 targetIdtargetRootprofilemodulesoperationsinstalledAtsourceVersion

六、边界与代价:这层东西不管什么

它不管内容对不对。 安装清单只保证 paths 里写的路径在仓库里存在,至于那个目录里的技能写得好不好、agent 的提示词是否合理,schema 一概不看。targets 里声明支持某个 harness,也只是声明,不代表在那个 harness 上实测通过。

additionalProperties: false 是一笔要长期还的账。 插件清单、状态存储、安装模块三份都关掉了额外属性。好处是拼错字段名会立刻炸出来;代价是任何一个实验性字段都没地方临时放,加字段要同时改 schema、改写入方、改测试。溯源那份反过来开着 additionalProperties: true,因为技能出处天然需要塞各种来源相关的信息。这两种选择没有优劣,但你得清楚自己选的是哪种。

契约和实现可能各走各的。 前面提过,溯源的校验是手写的,状态存储的校验才是从 schema 编译出来的;插件清单那份 schema 没有任何代码引用。这意味着 schemas 目录里的文件并非一致地具有强制力,读代码的时候得逐份确认谁在执行它。

它会往你的机器上写东西。 状态库默认落在 home 目录下的 .claude/ecc/state.db,里面记的是 taskDescriptionuserFeedbackrationale 这类自然语言字段,还有 tokensUsed。这些内容大概率包含你项目的上下文。它不加密、也不替你做保留期管理,要不要备份、什么时候清、能不能进公司合规范围,是你的事。相关取舍可以对照日志里的敏感信息怎么处理

装 hooks 就是同意在会话事件上跑本地脚本。 hooks-runtime 模块的 paths 覆盖 hooksscripts/hooksscripts/lib。这类东西装进去之后会在会话生命周期的某些时点执行,属于要看明白再装的部分。profiles 里 minimalopencode 的描述都明确写了不含 hooks 运行时,opencode 那条还注明可以用 --modules hooks-runtime 单独打开——这个设计说明维护者本身也把 hooks 当成需要显式选择的部分。

不适用的场景也说清楚。 如果你的 Agent 系统只有一个仓库、一个使用者、不往别人机器上装东西,这四份 schema 里至少三份对你是纯负担。分发范围越广、目标环境越多、参与者越杂,这套约束才越划算,单机自用没必要提前上。

七、上手与避坑清单

别拿自家 schema 当通行证。 会踩是因为本地校验绿了就以为万事大吉,而实际加载你组件的是外部运行时。ECC 的 agents 字段就是这么栽的:过了仓库 schema,被 Claude Code 校验器拒了。避法是把外部校验命令也接进检查流程,仓库文档里给的做法是跑 claude plugin validate .claude-plugin/plugin.json

加技能别只加目录。 会踩是因为写完 SKILL.md 就以为完事了,但 CI 会扫 skills/ 下所有含 SKILL.md 的目录,没被任何模块 paths 覆盖就直接报错。避法是新增技能的同时改 manifests/install-modules.json 里对应模块的 paths。

拆模块前先划路径归属。 会踩是因为顺手把一个已经被别的模块认领的目录写进新模块,脚本的路径唯一性检查会把两个 id 一起打出来。避法是拆之前先看清目标路径现在归谁,必要时先把原模块的 paths 拆细。

新模块记得进 full。 会踩是因为只顾着往 modules 数组里加,忘了 profiles 那边。校验脚本要求 full 包含所有模块,只放过 kind 为 docs 且 defaultInstall 为 false 的。避法是加模块时把 profiles 文件一起改。

状态实体不要省略可空字段。 会踩是因为习惯性地”没有值就不写这个键”,但那些字段虽然可空却在 required 列表里,少一个键 Ajv 直接判不通过。避法是写入前对着 schema 的 required 列表逐项给值,没有就显式给 null。

溯源的 confidence 别写成字符串。 会踩是因为从配置或环境变量里读出来的天然是字符串,"0.9" 看着没问题,但校验要求是 0 到 1 之间的数字类型,字符串会被拒。同理 created_at 必须是能被解析成时间的字符串。

别往仓库里的技能目录写溯源文件。 会踩是因为想”统一一下,都带上出处”,但 writeProvenance 对不属于 learned 或 imported 的路径会直接抛错。仓库自带技能的署名走 SKILL.md frontmatter 里的 origin 字段。

看到 "mcpServers": {} 别当垃圾清理掉。 会踩是因为空对象看起来像遗留代码。仓库文档写明这是刻意的退出开关,去掉之后 Claude 插件安装会自动发现仓库根目录的 .mcp.json,而由较长插件标识拼出来的 MCP 工具名会超出严格网关对工具名长度的上限,直接被拒。

收束:判断这层要不要加的三个问题

ECC 的规模摆在那里:agents 目录 67 个 agent、skills 目录 281 个技能、commands 目录 94 个命令,还要往十来种 harness 上装。到了这个量级,元数据靠人记是不可能的,所以它必须有一层机器可校验的契约。你的系统未必到这个量级,可以先问自己三个问题:

第一,你的组件会不会被复制到你控制不了的环境里?会,就需要安装清单那类东西。第二,你的系统会不会自己生成或从外部吸收能力单元?会,就需要溯源那类字段来区分信任等级。第三,你需不需要回答”上周那次会话为什么做了这个决定”?需要,就需要状态存储那类结构化实体,而不是一堆散落的日志文件。

想继续往下读代码的话,路径顺序建议是:先看 manifests/install-modules.json 建立整体结构感,再看 scripts/ci/validate-install-manifests.js 理解 schema 与脚本的分工,然后是 docs/SKILL-PLACEMENT-POLICY.md 那张四类技能的表,最后看 scripts/lib/state-store/schema.jsmigrations.js 这一对,体会同一套数据在校验层和存储层是怎么分别设防的。至于 .claude-plugin/PLUGIN_SCHEMA_NOTES.md,那份不用等到读代码,现在就值得看一遍——它记录的是一个真实项目和一个外部校验器反复拉扯的过程,比任何设计文档都更能说明契约层为什么必要。

本文属于 ECC 开源 Agent 套件专题(共 40 篇,从选择性安装一直拆到内部机制)。同一批还写了另一种取向的项目——不给你资产、只给你纪律的 superpowers 方法论专题

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。