mirror of
https://git.openapi.site/https://github.com/desirecore/market.git
synced 2026-09-05 15:33:52 +08:00
feat: 市场支持团队条目类型并上架合同审查团队 (#110)
## 变更 / What
市场此前只有 `agents` 与 `skills` 两类条目。本 PR 加入**团队(teams)**条目类型,并上架第一条真实团队
listing。
The market supported only `agents` and `skills`. This PR adds a
**teams** entry type and lists the first real team.
## 一、支持团队条目类型
「支持一种新条目类型」实际涉及 4 组共 11 个文件,比表面看到的多:
**客户端契约快照**
- 新增 `schemas/market-team-entry.client.schema.json`,用 esbuild 打包客户端
`packages/schemas/src/market.ts` 后导出生成。用同样方法重新生成
`market-agent-entry.client.schema.json` 验证过管线——字节完全一致(含属性顺序),确认不是手工誊抄。
**Sidecar schema**
- `identity.kind` 枚举加 `team`;新增 `$defs.teamSpec`;接入 `spec.oneOf` 与
kind→spec 派发
**校验器(工作量主要在这里)**
- `scripts/catalog/validate_catalog_metadata.py`:`load_legacy`
原先硬编码只认两个根目录。抽出 `CATALOG_ROOTS` 常量同时驱动允许的父目录集合与错误文案;按 kind 分派客户端 schema
校验;`teams` 进 stats 与 `--require-complete` 覆盖统计;把**严格 provenance
比对**与「可安装 pointer 必须自带不可变 ref」两道门禁扩展到团队
- `scripts/i18n/validate-i18n.py`:**它独立重算计数并逐个校验 `entry.json`**,不接团队会漏校
- `.github/workflows/i18n-validate.yml`:变更检测的 grep 不含 `teams/`——**一个只改
teams 的 PR 会报「无 i18n 相关变更,跳过校验」然后零校验通过**
- 测试:`test_validate_catalog_metadata.py` 29→47,`test_validate_i18n.py`
9→17
**顺带修正一条本就不对的规则**:`icon` 此前被要求「每个 entry.json 都必须有非空内联 SVG」,但运行时 schema 里
`marketAgentSchema` 与 `marketTeamSchema` **都没有 `icon` 字段**(只有 skill
有)。也就是说这条规则对 Agent pointer 同样在强加死重量,只因本仓库暂无 agent pointer 条目而未暴露。改为
`ICON_RENDERED_KINDS = {"skill"}`,agent/team 声明 icon
时给**警告**而非错误,文案说明「下一个维护者会以为改它能改变卡片」。
## 二、上架合同审查团队
`teams/contract-review-team/`(`entry.json` + sidecar)。
**团队条目是 fork 指针卡,不分发正文**:市场只存展示元数据 + git-only `source`,真实定义(`team.json`
/ `members.json` / `shared/`)在 `source.repoUrl` 指向的仓库里。安装即
`forkTeam`,更新即 `git pull`——组合固定,因此**没有** `installPolicy` /
`updatePolicy`。
| 字段 | 值 | 依据 |
|---|---|---|
| `source.ref` | `73cd87a9901cc548871927e9d5dbec8e4cc6c2b1` | v0.1.1
的**完整 SHA**。tag 不是可复现 pin,validator 有测试专门拒绝 |
| `latestVersion` | `0.1.1` | 上游真实 tag,与 `release.version` 交叉校验 |
| `license` | `MIT` | 上游仓库真有 LICENSE,已在 pinned ref 的快照中复验 |
| `redistribution` | `source-pointer-only` |
市场从不打包团队正文,只给指针——这是交付形态,与许可证宽松与否无关 |
| `requiredClientVersion` | `10.0.137` | 六个成员都声明了 `FileDigest`
内置工具,它随该版本发布 |
| `memberCount` / `memberNames` | 6 / 5 名 | schema 规定前者**含**组长、后者**不含**
|
| `availability` | `listing-only` | 见下 |
**`availability` 为什么不是 `installable`**:四项证据满足两项(不可变 pin ✓、已知 license
✓),缺的 `reviewedAt` 与 `governance.compliance`
本质是**一次尚未发生的治理审查**——需要具名方在具体日期针对这个确切 ref 审过许可合规、第三方内容与商标使用。没发生的事不能写进目录。
补充一个事实:本仓库**零个 sidecar 有 `compliance` 块,29 个 pointer 条目全是
listing-only**,`installable` 路径从未在任何真实条目上走过。这不阻止安装——fork 由 `source` 驱动。
**`license.evidencePath` 的基准此前是未定义的**:schema 只说
`safeRelativePath`,没规定相对谁。仓库里仅有的两个先例(`guizang-ppt`、`presentation-forge`)都是
vendored 技能,LICENSE 物理上在条目目录里。按那个读法,pointer 条目写 `evidencePath`
断言的是市场目录下有该文件——对 pointer 永远不成立。新增 `license-evidence` 规则按条目形态分派:vendored
要求文件存在(error),pointer 要求条目已 pin(warning),两种读法写进 README。
## 校验 / Validation
```
test_validate_catalog_metadata.py 47 tests OK
test_validate_i18n.py 17 tests OK
test_collection_generator.py exit 0
validate_catalog_metadata.py --require-complete
0 error, 116 warning (agents=1, teams=1, publishableSkills=62, sidecars=64)
validate-i18n.py / --online 0 error, 116 warning
translate.py --check exit 0
gen-collection-children.py --check exit 0
```
116 warnings 即加入团队之前的基线——**本条 listing 贡献 0 个警告**。
真实条目上的反向控制(跑在 rsync 副本上,仓库保持干净):
```
source.kind=zip → team-entry-schema (error)
install/updatePolicy 出现 → team-entry-schema (error)
requiredClientVersion 漂移 → legacy-consistency (error)
memberCount 漂移 → legacy-consistency (error)
provenance ref 漂移 → legacy-consistency (error)
可安装但无不可变 ref → installable-evidence (error)
evidencePath 在未 pin 的 pointer → license-evidence (warning)
```
另用**客户端真实校验器**(`parseMarketTeamEntry`,不是快照)验证条目通过,且多写一个字段会被拒。
## 公开信息边界 / Public information boundary
全树扫描无新增命中。团队内容使用「某某科技(北京)有限公司」这类标准中文占位。
---------
Co-authored-by: yi-ge <mizan57533@gmail.com>
This commit is contained in:
2
.github/workflows/i18n-validate.yml
vendored
2
.github/workflows/i18n-validate.yml
vendored
@@ -48,7 +48,7 @@ jobs:
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
changed=$(git diff --name-only "$range" || true)
|
changed=$(git diff --name-only "$range" || true)
|
||||||
if echo "$changed" | grep -qE '^(skills/|agents/|schemas/|manifest\.json$|categories\.json$|scripts/i18n/|scripts/catalog/|scripts/gen-collection-children\.py$)'; then
|
if echo "$changed" | grep -qE '^(skills/|agents/|teams/|schemas/|manifest\.json$|categories\.json$|scripts/i18n/|scripts/catalog/|scripts/gen-collection-children\.py$)'; then
|
||||||
echo "relevant=true" >> "$GITHUB_OUTPUT"
|
echo "relevant=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "relevant=false" >> "$GITHUB_OUTPUT"
|
echo "relevant=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ customer-neutral, and safe to index publicly.
|
|||||||
## Public information boundary
|
## Public information boundary
|
||||||
|
|
||||||
- Never put a tenant, customer, prospect, partner, or other confidential identity
|
- Never put a tenant, customer, prospect, partner, or other confidential identity
|
||||||
in tracked files, paths, filenames, Agent or Skill IDs, frontmatter, descriptions,
|
in tracked files, paths, filenames, Agent, Team or Skill IDs, frontmatter, descriptions,
|
||||||
examples, fixtures, memories, generated artifacts, or screenshots.
|
examples, fixtures, memories, generated artifacts, or screenshots.
|
||||||
- Apply the same rule to Git metadata and collaboration text: branch names, commit
|
- Apply the same rule to Git metadata and collaboration text: branch names, commit
|
||||||
subjects and bodies, PR or issue titles and bodies, comments, and review replies.
|
subjects and bodies, PR or issue titles and bodies, comments, and review replies.
|
||||||
@@ -19,7 +19,7 @@ customer-neutral, and safe to index publicly.
|
|||||||
|
|
||||||
## External dependency disclosure
|
## External dependency disclosure
|
||||||
|
|
||||||
- A Skill or Agent that relies on separately licensed, purchased, hosted, or
|
- A Skill, Agent or Team that relies on separately licensed, purchased, hosted, or
|
||||||
deployed third-party software must disclose that dependency in its discovery
|
deployed third-party software must disclose that dependency in its discovery
|
||||||
description, `compatibility` field, localized marketplace text, and execution
|
description, `compatibility` field, localized marketplace text, and execution
|
||||||
instructions.
|
instructions.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ customer-neutral, and safe to index publicly.
|
|||||||
## Public information boundary
|
## Public information boundary
|
||||||
|
|
||||||
- Never put a tenant, customer, prospect, partner, or other confidential identity
|
- Never put a tenant, customer, prospect, partner, or other confidential identity
|
||||||
in tracked files, paths, filenames, Agent or Skill IDs, frontmatter, descriptions,
|
in tracked files, paths, filenames, Agent, Team or Skill IDs, frontmatter, descriptions,
|
||||||
examples, fixtures, memories, generated artifacts, or screenshots.
|
examples, fixtures, memories, generated artifacts, or screenshots.
|
||||||
- Apply the same rule to Git metadata and collaboration text: branch names, commit
|
- Apply the same rule to Git metadata and collaboration text: branch names, commit
|
||||||
subjects and bodies, PR or issue titles and bodies, comments, and review replies.
|
subjects and bodies, PR or issue titles and bodies, comments, and review replies.
|
||||||
@@ -19,7 +19,7 @@ customer-neutral, and safe to index publicly.
|
|||||||
|
|
||||||
## External dependency disclosure
|
## External dependency disclosure
|
||||||
|
|
||||||
- A Skill or Agent that relies on separately licensed, purchased, hosted, or
|
- A Skill, Agent or Team that relies on separately licensed, purchased, hosted, or
|
||||||
deployed third-party software must disclose that dependency in its discovery
|
deployed third-party software must disclose that dependency in its discovery
|
||||||
description, `compatibility` field, localized marketplace text, and execution
|
description, `compatibility` field, localized marketplace text, and execution
|
||||||
instructions.
|
instructions.
|
||||||
|
|||||||
106
README.md
106
README.md
@@ -1,6 +1,6 @@
|
|||||||
# DesireCore Market
|
# DesireCore Market
|
||||||
|
|
||||||
DesireCore 官方市场仓库,存放官方维护的 Agent/Skill 定义,以及经过整理的第三方 Skill 入口。
|
DesireCore 官方市场仓库,存放官方维护的 Agent/Team/Skill 定义,以及经过整理的第三方 Skill 入口。
|
||||||
|
|
||||||
## Repository Shape
|
## Repository Shape
|
||||||
|
|
||||||
@@ -12,6 +12,9 @@ DesireCore 官方市场仓库,存放官方维护的 Agent/Skill 定义,以
|
|||||||
├── agents/
|
├── agents/
|
||||||
│ └── desirecore/
|
│ └── desirecore/
|
||||||
│ └── agent.json
|
│ └── agent.json
|
||||||
|
├── teams/
|
||||||
|
│ └── <team>/
|
||||||
|
│ └── entry.json
|
||||||
└── skills/
|
└── skills/
|
||||||
├── <local-skill>/
|
├── <local-skill>/
|
||||||
│ ├── SKILL.md
|
│ ├── SKILL.md
|
||||||
@@ -23,6 +26,7 @@ DesireCore 官方市场仓库,存放官方维护的 Agent/Skill 定义,以
|
|||||||
The market currently contains:
|
The market currently contains:
|
||||||
|
|
||||||
- `1` Agent: `desirecore`
|
- `1` Agent: `desirecore`
|
||||||
|
- `1` Team: `contract-review-team`
|
||||||
- `34` local built-in skills with `SKILL.md`
|
- `34` local built-in skills with `SKILL.md`
|
||||||
- `28` external skill entries with `entry.json`
|
- `28` external skill entries with `entry.json`
|
||||||
- `62` publishable skills in total (`SKILL.md` + `entry.json`)
|
- `62` publishable skills in total (`SKILL.md` + `entry.json`)
|
||||||
@@ -128,6 +132,70 @@ External entries point to upstream packages or repositories. They are counted in
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Team Listing (`teams/<id>/entry.json`)
|
||||||
|
|
||||||
|
A Team is a group of Agents with a supervisor. It is published as a **fork pointer
|
||||||
|
only**: the team body (`team.json`, `members.json`, `shared/`) always stays in the
|
||||||
|
upstream team repository, and the catalog registers just "what it is and where to
|
||||||
|
fork it from". Installation forks that repository and installs the declared members;
|
||||||
|
updates are a `git pull` on the fork. Because both actions are Git actions,
|
||||||
|
`source.kind` must be `git` and `source.repoUrl` is required — `zip` and `web` cannot
|
||||||
|
express either one — and a Team deliberately has **no** `installPolicy` /
|
||||||
|
`updatePolicy` pair: it is always market-initiated fork plus repository-driven update.
|
||||||
|
|
||||||
|
`teams/<id>/` therefore holds exactly `entry.json` plus the sidecar. There is no
|
||||||
|
inline form; a `team.json` in the catalog is rejected.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "example-team",
|
||||||
|
"name": "Example Team",
|
||||||
|
"category": "development",
|
||||||
|
"tags": ["example"],
|
||||||
|
"latestVersion": "0.1.0",
|
||||||
|
"maintainer": {
|
||||||
|
"name": "Example",
|
||||||
|
"verified": false,
|
||||||
|
"account": "example",
|
||||||
|
"url": "https://github.com/example"
|
||||||
|
},
|
||||||
|
"stewardship": "community",
|
||||||
|
"license": "MIT",
|
||||||
|
"redistribution": "source-pointer-only",
|
||||||
|
"source": {
|
||||||
|
"kind": "git",
|
||||||
|
"repoUrl": "https://github.com/example/example-team.git",
|
||||||
|
"repoBranch": "main",
|
||||||
|
"ref": "0123456789abcdef0123456789abcdef01234567"
|
||||||
|
},
|
||||||
|
"requiredClientVersion": "10.0.0",
|
||||||
|
"avatar": { "t": "示", "bg": "linear-gradient(135deg, #5856D6, #3634A3)" },
|
||||||
|
"supervisorName": "Example Supervisor",
|
||||||
|
"supervisorAgentId": "example-lead",
|
||||||
|
"memberCount": 3,
|
||||||
|
"memberNames": ["Example Member One", "Example Member Two"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A Team card is rendered from `avatar`, not from `icon`. The client's runtime
|
||||||
|
projection `marketTeamSchema` requires `avatar` and has no `icon` property at all —
|
||||||
|
the same is true of `marketAgentSchema`, while only `marketSkillSchema` exposes
|
||||||
|
`icon`. The entry contract inherits `icon` from the shared common properties, so it
|
||||||
|
is *accepted*, but it can never reach a card. The validator therefore requires `icon`
|
||||||
|
on Skill listings only, and warns when an Agent or Team listing declares one.
|
||||||
|
|
||||||
|
`redistribution` stays `source-pointer-only` for a Team even under a permissive
|
||||||
|
license: the market never ships the team body, it only points at the repository the
|
||||||
|
client forks. The license governs what a fork may do; `redistribution` describes how
|
||||||
|
the content is delivered, and for teams that is always "fetch from upstream".
|
||||||
|
|
||||||
|
`supervisorName`, `supervisorAgentId`, `memberCount`, `memberNames` and
|
||||||
|
`requiredSkills` are display metadata declared by the publisher. They may drift from
|
||||||
|
the upstream repository, so installation, permissions and member resolution must read
|
||||||
|
the forked `team.json` / `members.json` instead. Teams are counted in
|
||||||
|
`manifest.stats.totalTeams`, which the client keeps optional: a catalog with no teams
|
||||||
|
may omit it, and once the key is present it must be exact.
|
||||||
|
|
||||||
### Catalog metadata sidecar (`catalog-metadata.v1.json`)
|
### Catalog metadata sidecar (`catalog-metadata.v1.json`)
|
||||||
|
|
||||||
The versioned catalog metadata contract is stored at one fixed path next to each
|
The versioned catalog metadata contract is stored at one fixed path next to each
|
||||||
@@ -135,6 +203,7 @@ legacy item:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
agents/<id>/catalog-metadata.v1.json
|
agents/<id>/catalog-metadata.v1.json
|
||||||
|
teams/<id>/catalog-metadata.v1.json
|
||||||
skills/<id>/catalog-metadata.v1.json
|
skills/<id>/catalog-metadata.v1.json
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -177,12 +246,45 @@ Pointer 的目录 slug、`entry.id`、sidecar `identity.id` 必须一致;上
|
|||||||
可安装指针自身必须固定 Git ref 或 Web/ZIP 摘要,不能只在 sidecar 宣称不可变版本。
|
可安装指针自身必须固定 Git ref 或 Web/ZIP 摘要,不能只在 sidecar 宣称不可变版本。
|
||||||
现有许可、治理审查、不可变来源和完整覆盖门禁继续有效,不适用内置 Skill 的宽松例外。
|
现有许可、治理审查、不可变来源和完整覆盖门禁继续有效,不适用内置 Skill 的宽松例外。
|
||||||
|
|
||||||
|
Team listings are pointer-only, so `teams/<slug>/` carries exactly `entry.json` plus
|
||||||
|
the sidecar; an inline `team.json` is rejected. Team pointers first pass the complete
|
||||||
|
raw client contract in
|
||||||
|
[`schemas/market-team-entry.client.schema.json`](schemas/market-team-entry.client.schema.json),
|
||||||
|
exported from `marketTeamEntrySchema` the same way as the Agent snapshot; its
|
||||||
|
`$comment` records the source commit and blob. `entry.id`, the directory slug and
|
||||||
|
sidecar `identity.id` must agree, `identity.kind` is `team`, and `latestVersion` maps
|
||||||
|
to `release.version`. `supervisorName`, `supervisorAgentId`, `memberCount`,
|
||||||
|
`memberNames`, `requiredSkills` and `requiredClientVersion` are compared symmetrically:
|
||||||
|
the sidecar may neither drop a fact the pointer declares nor invent one it omits,
|
||||||
|
because the client reads the pointer and a version gate that exists only in the
|
||||||
|
sidecar would not gate anything. Pointer source fields must describe the same
|
||||||
|
artifact as `provenance.content`, and an installable Team pointer must itself pin a
|
||||||
|
full-SHA `source.ref` — a tag is not a reproducible pin, because a tag can be moved
|
||||||
|
to a different commit after the listing is reviewed.
|
||||||
|
|
||||||
|
团队条目只有指针形态:`teams/<slug>/` 仅放 `entry.json` 与 sidecar,目录内出现 `team.json` 直接判非法。
|
||||||
|
Pointer 原始 JSON 先通过由 `marketTeamEntrySchema` 导出的完整客户端 Schema;
|
||||||
|
`source.kind` 恒为 `git` 且必须有 `repoUrl`,团队没有 `installPolicy` / `updatePolicy` 组合。
|
||||||
|
目录 slug、`entry.id`、sidecar `identity.id` 必须一致,`identity.kind` 为 `team`。
|
||||||
|
展示字段与最低客户端版本双向比对:sidecar 既不得丢弃指针声明的事实,也不得凭空补上指针没有的事实。
|
||||||
|
可安装团队指针自身必须固定完整 SHA 的 `source.ref`,tag 或分支不算可复现锁定。
|
||||||
|
|
||||||
The sidecar records source-owned presentation, release, timestamp, content
|
The sidecar records source-owned presentation, release, timestamp, content
|
||||||
provenance, governance, compatibility, and type-specific facts. It deliberately
|
provenance, governance, compatibility, and type-specific facts. It deliberately
|
||||||
cannot declare `catalogSourceId`, catalog commit/path/trust, effective official
|
cannot declare `catalogSourceId`, catalog commit/path/trust, effective official
|
||||||
status, installation state, device state, health, URLs discovered at runtime, or
|
status, installation state, device state, health, URLs discovered at runtime, or
|
||||||
`syncedAt`. DesireCore injects trusted catalog provenance and runtime facts.
|
`syncedAt`. DesireCore injects trusted catalog provenance and runtime facts.
|
||||||
|
|
||||||
|
`license.evidencePath`, `compliance.licenseEvidencePath` and `compliance.noticePath`
|
||||||
|
resolve differently by item shape, because the schema constrains only the string
|
||||||
|
form. Vendored content (built-in Skills, inline Agents) ships inside this repository,
|
||||||
|
so the path is relative to the catalog item directory and the file must actually be
|
||||||
|
there — a missing file is an error. A pointer distributes nothing, so its evidence
|
||||||
|
can only be inside the upstream snapshot at the pinned revision; the validator cannot
|
||||||
|
read that offline, so it warns when such a claim is made against an unpinned pointer.
|
||||||
|
Pin `source.ref` to a full commit SHA and the claim becomes falsifiable by anyone who
|
||||||
|
fetches it.
|
||||||
|
|
||||||
Time facts are explicit `known`/`unknown` values. A known day uses
|
Time facts are explicit `known`/`unknown` values. A known day uses
|
||||||
`YYYY-MM-DD` with `precision: "day"`; a known second uses an RFC 3339 UTC value
|
`YYYY-MM-DD` with `precision: "day"`; a known second uses an RFC 3339 UTC value
|
||||||
ending in `Z` with `precision: "second"`. Never use the current date, clone time,
|
ending in `Z` with `precision: "second"`. Never use the current date, clone time,
|
||||||
@@ -235,7 +337,7 @@ evidence, collection identity, i18n completeness, and translation freshness.
|
|||||||
Human-locked translations (`translated_by: human`) must keep `source_hash`
|
Human-locked translations (`translated_by: human`) must keep `source_hash`
|
||||||
aligned after manual review. During a data migration,
|
aligned after manual review. During a data migration,
|
||||||
`scripts/catalog/validate_catalog_metadata.py --require-complete` additionally
|
`scripts/catalog/validate_catalog_metadata.py --require-complete` additionally
|
||||||
requires one sidecar for every top-level Agent and Skill.
|
requires one sidecar for every top-level Agent, Team and Skill.
|
||||||
|
|
||||||
Detailed i18n guidance is in [docs/I18N.md](docs/I18N.md).
|
Detailed i18n guidance is in [docs/I18N.md](docs/I18N.md).
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "DesireCore Official Market",
|
"name": "DesireCore Official Market",
|
||||||
"version": "1.3.1",
|
"version": "1.4.0",
|
||||||
"schemaVersion": "1.1.0",
|
"schemaVersion": "1.1.0",
|
||||||
"supportedLocales": ["zh-CN", "en-US"],
|
"supportedLocales": ["zh-CN", "en-US"],
|
||||||
"defaultLocale": "en-US",
|
"defaultLocale": "en-US",
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"totalAgents": 1,
|
"totalAgents": 1,
|
||||||
|
"totalTeams": 1,
|
||||||
"totalSkills": 62,
|
"totalSkills": 62,
|
||||||
"lastUpdated": "2026-09-01"
|
"lastUpdated": "2026-09-01"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
"$id": "https://desirecore.com/schemas/market/catalog-metadata.v1.schema.json",
|
"$id": "https://desirecore.com/schemas/market/catalog-metadata.v1.schema.json",
|
||||||
"title": "DesireCore Market catalog metadata sidecar v1",
|
"title": "DesireCore Market catalog metadata sidecar v1",
|
||||||
"description": "Strict source-owned metadata stored next to one legacy Market Agent or Skill. The trusted catalog source ID, catalog commit, catalog path and catalog trust are injected by DesireCore and are intentionally prohibited here.",
|
"description": "Strict source-owned metadata stored next to one legacy Market Agent, Team or Skill. The trusted catalog source ID, catalog commit, catalog path and catalog trust are injected by DesireCore and are intentionally prohibited here.",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"schemaVersion",
|
"schemaVersion",
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
"$schema": {
|
"$schema": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "../../schemas/catalog-metadata.v1.schema.json",
|
"const": "../../schemas/catalog-metadata.v1.schema.json",
|
||||||
"description": "Relative schema reference from agents/<id>/ or skills/<id>/."
|
"description": "Relative schema reference from agents/<id>/, teams/<id>/ or skills/<id>/."
|
||||||
},
|
},
|
||||||
"schemaVersion": {
|
"schemaVersion": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
"spec": {
|
"spec": {
|
||||||
"oneOf": [
|
"oneOf": [
|
||||||
{ "$ref": "#/$defs/agentSpec" },
|
{ "$ref": "#/$defs/agentSpec" },
|
||||||
|
{ "$ref": "#/$defs/teamSpec" },
|
||||||
{ "$ref": "#/$defs/skillSpec" }
|
{ "$ref": "#/$defs/skillSpec" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -71,6 +72,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"if": {
|
||||||
|
"properties": {
|
||||||
|
"identity": {
|
||||||
|
"properties": { "kind": { "const": "team" } },
|
||||||
|
"required": ["kind"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"properties": {
|
||||||
|
"spec": { "$ref": "#/$defs/teamSpec" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"if": {
|
"if": {
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -158,7 +174,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"kind": {
|
"kind": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": ["agent", "skill"]
|
"enum": ["agent", "team", "skill"]
|
||||||
},
|
},
|
||||||
"id": {
|
"id": {
|
||||||
"$ref": "#/$defs/slug"
|
"$ref": "#/$defs/slug"
|
||||||
@@ -521,6 +537,38 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
|
"teamSpec": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Team facts owned by the listing. A Team is always a fork pointer: installation forks the team repository and updates are a git pull, so a Team never declares installPolicy or updatePolicy. Every field here is presentation metadata that may drift from the forked repository; membership, permissions and capability resolution must read the forked team.json and members.json instead.",
|
||||||
|
"required": ["kind"],
|
||||||
|
"properties": {
|
||||||
|
"kind": { "const": "team" },
|
||||||
|
"supervisorName": { "type": "string", "minLength": 1, "maxLength": 200 },
|
||||||
|
"supervisorAgentId": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 160,
|
||||||
|
"pattern": "^[A-Za-z0-9_-]+$"
|
||||||
|
},
|
||||||
|
"memberCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"description": "Total member count including the supervisor."
|
||||||
|
},
|
||||||
|
"memberNames": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 32,
|
||||||
|
"items": { "type": "string", "minLength": 1, "maxLength": 200 }
|
||||||
|
},
|
||||||
|
"requiredSkills": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 128,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": { "type": "string", "minLength": 1, "maxLength": 160 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"collectionChild": {
|
"collectionChild": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["identity", "path", "presentation", "release"],
|
"required": ["identity", "path", "presentation", "release"],
|
||||||
|
|||||||
219
schemas/market-team-entry.client.schema.json
Normal file
219
schemas/market-team-entry.client.schema.json
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name",
|
||||||
|
"stewardship",
|
||||||
|
"license",
|
||||||
|
"redistribution",
|
||||||
|
"source"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "团队条目唯一标识符,采用与 team.json#id 相同的团队 slug 规则(不允许首尾连字符),保证市场 ID 天然是一个合法团队目录名。",
|
||||||
|
"pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "默认显示名称(源 locale),本地化变体见 i18n"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^[a-z0-9-]+$",
|
||||||
|
"description": "市场项目分类(slug 格式),由仓库 categories.json 定义"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": "标签列表,用于搜索和分类"
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "市场列表展示的内联 SVG 图标。**只有 Skill 卡片渲染它**——marketAgentSchema 与 marketTeamSchema 都没有 icon 字段,Agent 与团队卡片一律走 avatar,条目声明了 icon 也不会显示。Pointer 技能将其随离线元数据返回。"
|
||||||
|
},
|
||||||
|
"latestVersion": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "市场登记的最新版本(semver)",
|
||||||
|
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||||
|
},
|
||||||
|
"i18n": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "按 locale 提供的本地化展示元数据(仅 name/shortDesc,轻量)",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "该 locale 下的显示名称"
|
||||||
|
},
|
||||||
|
"shortDesc": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "该 locale 下的简短描述"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"maintainer": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"verified"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "维护者名称,如 \"DesireCore Official\""
|
||||||
|
},
|
||||||
|
"verified": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "是否官方认证维护者,认证后显示蓝色勾选标记"
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "维护方账号标识,如 GitHub 组织/用户名 \"desirecore\""
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "维护方主页或仓库 URL"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"stewardship": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"official",
|
||||||
|
"partner",
|
||||||
|
"community",
|
||||||
|
"pointer"
|
||||||
|
],
|
||||||
|
"description": "维护方分类:official(官方)/ partner(合作伙伴官方)/ community(社区已收录)/ pointer(仅指针,不分发内容)"
|
||||||
|
},
|
||||||
|
"license": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "许可证:SPDX id(MIT/Apache-2.0/MIT-0…)或特殊值 not-declared / source-available / packaged-distribution"
|
||||||
|
},
|
||||||
|
"redistribution": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"allowed",
|
||||||
|
"source-pointer-only",
|
||||||
|
"verify-package-terms"
|
||||||
|
],
|
||||||
|
"description": "再分发策略:allowed(可分发)/ source-pointer-only(仅指针,安装时拉取)/ verify-package-terms(需核验条款)"
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"kind",
|
||||||
|
"repoUrl"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"git"
|
||||||
|
],
|
||||||
|
"description": "团队只支持 git 源:安装是 fork 团队仓库、更新是 git pull,zip / web 无法表达这两个动作。"
|
||||||
|
},
|
||||||
|
"repoUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "团队 git 仓库的 HTTPS 克隆地址,如 https://github.com/desirecore/team-contract-review.git"
|
||||||
|
},
|
||||||
|
"repoBranch": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "团队仓库分支名称,默认 main。安装时会真的按这个分支克隆;必须是合法分支名(不带 refs/ 前缀、不以 - 开头),否则安装失败关闭而不是静默改装默认分支。",
|
||||||
|
"default": "main"
|
||||||
|
},
|
||||||
|
"ref": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "锁定的 commit SHA / tag,用于可复现安装;为空表示取分支 HEAD。只接受**完整 40 位 SHA-1** 或标签名:缩写 oid 会随仓库增长变歧义、也远比完整 SHA 容易被构造碰撞,而降级 git 后端根本不解析它。安装时会按该 ref 克隆并读回真实 HEAD 逐字复核,不一致就中止整个安装——一个不被兑现的锁比没有锁更危险,因为它会被信任。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "团队 fork 源。**这是团队条目的核心**:安装端点用 repoUrl + repoBranch 调用 forkTeam,更新用同一仓库 git pull。"
|
||||||
|
},
|
||||||
|
"requiredClientVersion": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "此条目要求的最低客户端版本(semver),低于此版本时提示升级",
|
||||||
|
"pattern": "^\\d+\\.\\d+\\.\\d+$"
|
||||||
|
},
|
||||||
|
"avatar": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"t",
|
||||||
|
"bg"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"t": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "头像显示的文字(通常为一个汉字)",
|
||||||
|
"maxLength": 2
|
||||||
|
},
|
||||||
|
"bg": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "头像背景 CSS 渐变值,如 linear-gradient(135deg, #007AFF, #005ECB)"
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "图片头像:市场仓库中该条目目录内的位图(如 \"assets/avatar.webp\")。存在且文件可读时优先于 t + bg 渲染,否则自动回落——所以 t 与 bg 仍为必填,它们是确定的回落形态。仅支持 PNG / JPEG / WebP。注意:这是**市场展示**元数据,与安装后 agent.json 的 avatar.image 是两套,安装时不迁移。",
|
||||||
|
"required": [
|
||||||
|
"path"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "相对于该市场条目目录的图片路径。禁止绝对路径和 .. 路径穿越",
|
||||||
|
"minLength": 5,
|
||||||
|
"maxLength": 240,
|
||||||
|
"pattern": "^(?![\\\\/])(?![A-Za-z]:)(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+\\.(?:png|jpe?g|webp)$"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"supervisorName": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "组长(supervisor)Agent 的显示名称,仅用于卡片与详情页展示;真实组长以团队仓库 team.json#supervisor 为准。"
|
||||||
|
},
|
||||||
|
"supervisorAgentId": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "组长 Agent ID(展示与去重用)。只是发布者声明,不作为安装期白名单依据。",
|
||||||
|
"pattern": "^[A-Za-z0-9_-]+$"
|
||||||
|
},
|
||||||
|
"memberCount": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"description": "团队成员总数(**含组长**),用于卡片上\"N 名成员\"的展示;不参与任何安装校验。"
|
||||||
|
},
|
||||||
|
"memberNames": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"maxItems": 32,
|
||||||
|
"description": "成员显示名预览列表(不含组长),用于卡片上的成员缩略展示;不保证与仓库成员完全一致。"
|
||||||
|
},
|
||||||
|
"requiredSkills": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"maxItems": 128,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"description": "团队声明的必需 Skill ID,用于安装前向用户提示前置条件;真实校验由安装后的团队 capability audit 执行。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"$comment": "Generated from desirecore/desirecore packages/schemas/src/market.ts#marketTeamEntrySchema at commit 947d9f32c6c81ebe5ece78f6295155814c888a22, source blob d6b361d149773e7384babe3011d86d25ded4e493. Keep the complete client contract; regenerate from that export instead of editing fields manually."
|
||||||
|
}
|
||||||
@@ -53,6 +53,83 @@ def valid_entry() -> dict[str, object]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def valid_team_entry() -> dict[str, object]:
|
||||||
|
"""A Team listing: a git fork pointer plus display facts, and nothing else."""
|
||||||
|
return {
|
||||||
|
"id": "example-team",
|
||||||
|
"name": "Example Team",
|
||||||
|
"category": "development",
|
||||||
|
"tags": ["example"],
|
||||||
|
"latestVersion": "0.1.0",
|
||||||
|
"i18n": {
|
||||||
|
"zh-CN": {"name": "示例团队", "shortDesc": "中文团队简介"},
|
||||||
|
"en-US": {"name": "Example Team", "shortDesc": "English team summary"},
|
||||||
|
},
|
||||||
|
"maintainer": {"name": "Example Maintainer", "verified": False},
|
||||||
|
"stewardship": "community",
|
||||||
|
"license": "MIT",
|
||||||
|
"redistribution": "source-pointer-only",
|
||||||
|
"source": {
|
||||||
|
"kind": "git",
|
||||||
|
"repoUrl": "https://example.com/example-team.git",
|
||||||
|
"repoBranch": "main",
|
||||||
|
"ref": "a" * 40,
|
||||||
|
},
|
||||||
|
"supervisorName": "Example Supervisor",
|
||||||
|
"supervisorAgentId": "example-lead",
|
||||||
|
"memberCount": 3,
|
||||||
|
"memberNames": ["Example Member One", "Example Member Two"],
|
||||||
|
"requiredSkills": ["example-skill"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def valid_team_sidecar() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"$schema": "../../schemas/catalog-metadata.v1.schema.json",
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"identity": {"kind": "team", "id": "example-team"},
|
||||||
|
"presentation": {
|
||||||
|
"defaultLocale": "en-US",
|
||||||
|
"i18n": {
|
||||||
|
"zh-CN": {"name": "示例团队", "summary": "中文团队简介"},
|
||||||
|
"en-US": {"name": "Example Team", "summary": "English team summary"},
|
||||||
|
},
|
||||||
|
"category": "development",
|
||||||
|
"tags": ["example"],
|
||||||
|
},
|
||||||
|
"release": {"state": "known", "version": "0.1.0", "versionScheme": "semver"},
|
||||||
|
"timestamps": {
|
||||||
|
"catalogUpdatedAt": unknown(),
|
||||||
|
"releasePublishedAt": unknown(),
|
||||||
|
"reviewedAt": unknown(),
|
||||||
|
"upstreamObservedAt": unknown(),
|
||||||
|
},
|
||||||
|
"provenance": {
|
||||||
|
"content": {
|
||||||
|
"kind": "git",
|
||||||
|
"url": "https://example.com/example-team.git",
|
||||||
|
"ref": "a" * 40,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"governance": {
|
||||||
|
"stewardship": "community",
|
||||||
|
"availability": "listing-only",
|
||||||
|
"license": {"state": "known", "value": "MIT"},
|
||||||
|
"redistribution": "source-pointer-only",
|
||||||
|
"upstreamMaintainer": {"name": "Example Maintainer", "verified": False},
|
||||||
|
},
|
||||||
|
"compatibility": {"platforms": unknown()},
|
||||||
|
"spec": {
|
||||||
|
"kind": "team",
|
||||||
|
"supervisorName": "Example Supervisor",
|
||||||
|
"supervisorAgentId": "example-lead",
|
||||||
|
"memberCount": 3,
|
||||||
|
"memberNames": ["Example Member One", "Example Member Two"],
|
||||||
|
"requiredSkills": ["example-skill"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def valid_sidecar() -> dict[str, object]:
|
def valid_sidecar() -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"$schema": "../../schemas/catalog-metadata.v1.schema.json",
|
"$schema": "../../schemas/catalog-metadata.v1.schema.json",
|
||||||
@@ -99,8 +176,10 @@ class CatalogMetadataValidatorTests(unittest.TestCase):
|
|||||||
self.root = Path(self.tempdir.name)
|
self.root = Path(self.tempdir.name)
|
||||||
(self.root / "schemas").mkdir()
|
(self.root / "schemas").mkdir()
|
||||||
shutil.copyfile(SOURCE_SCHEMA, self.root / "schemas" / SOURCE_SCHEMA.name)
|
shutil.copyfile(SOURCE_SCHEMA, self.root / "schemas" / SOURCE_SCHEMA.name)
|
||||||
shutil.copyfile(SOURCE_SCHEMA.with_name(VALIDATOR.AGENT_ENTRY_SCHEMA_NAME), self.root / "schemas" / VALIDATOR.AGENT_ENTRY_SCHEMA_NAME)
|
for client_schema in (VALIDATOR.AGENT_ENTRY_SCHEMA_NAME, VALIDATOR.TEAM_ENTRY_SCHEMA_NAME):
|
||||||
|
shutil.copyfile(SOURCE_SCHEMA.with_name(client_schema), self.root / "schemas" / client_schema)
|
||||||
(self.root / "agents").mkdir()
|
(self.root / "agents").mkdir()
|
||||||
|
(self.root / "teams").mkdir()
|
||||||
(self.root / "skills" / "example-skill").mkdir(parents=True)
|
(self.root / "skills" / "example-skill").mkdir(parents=True)
|
||||||
self.write_json(
|
self.write_json(
|
||||||
self.root / "manifest.json",
|
self.root / "manifest.json",
|
||||||
@@ -218,6 +297,254 @@ class CatalogMetadataValidatorTests(unittest.TestCase):
|
|||||||
})
|
})
|
||||||
return entry_path, sidecar_path
|
return entry_path, sidecar_path
|
||||||
|
|
||||||
|
def write_team_pointer_case(self, mutate_entry=None, mutate_sidecar=None) -> tuple[Path, Path]:
|
||||||
|
team_dir = self.root / "teams" / "example-team"
|
||||||
|
entry = valid_team_entry()
|
||||||
|
sidecar = valid_team_sidecar()
|
||||||
|
if mutate_entry:
|
||||||
|
mutate_entry(entry)
|
||||||
|
if mutate_sidecar:
|
||||||
|
mutate_sidecar(sidecar)
|
||||||
|
entry_path = team_dir / "entry.json"
|
||||||
|
sidecar_path = team_dir / VALIDATOR.SIDECAR_NAME
|
||||||
|
self.write_json(entry_path, entry)
|
||||||
|
self.write_json(sidecar_path, sidecar)
|
||||||
|
self.write_json(self.root / "manifest.json", {
|
||||||
|
"supportedLocales": ["zh-CN", "en-US"],
|
||||||
|
"stats": {"totalAgents": 0, "totalTeams": 1, "totalSkills": 1},
|
||||||
|
})
|
||||||
|
return entry_path, sidecar_path
|
||||||
|
|
||||||
|
def test_accepts_complete_team_pointer(self) -> None:
|
||||||
|
entry_path, _ = self.write_team_pointer_case()
|
||||||
|
report = self.validate(require_complete=True)
|
||||||
|
self.assertFalse(report.has_errors, report.issues)
|
||||||
|
self.assertEqual(1, report.stats["teams"])
|
||||||
|
self.assertEqual(2, report.stats["sidecars"])
|
||||||
|
# A Team never carries an inline body in the catalog.
|
||||||
|
self.assertFalse(entry_path.with_name("team.json").exists())
|
||||||
|
|
||||||
|
def test_rejects_team_sidecar_conflicts(self) -> None:
|
||||||
|
mutations = {
|
||||||
|
"identity": lambda p: p["identity"].update(id="other-team"),
|
||||||
|
"kind": lambda p: (p["identity"].update(kind="skill"), p.update(spec={"kind": "skill"})),
|
||||||
|
"version": lambda p: p["release"].update(version="9.9.9"),
|
||||||
|
"source-kind": lambda p: p["provenance"]["content"].update(kind="web"),
|
||||||
|
"source-url": lambda p: p["provenance"]["content"].update(url="https://example.com/other.git"),
|
||||||
|
"source-ref": lambda p: p["provenance"]["content"].update(ref="b" * 40),
|
||||||
|
"category": lambda p: p["presentation"].update(category="research"),
|
||||||
|
"tags": lambda p: p["presentation"].update(tags=["other"]),
|
||||||
|
"summary": lambda p: p["presentation"]["i18n"]["en-US"].update(summary="Different"),
|
||||||
|
"license": lambda p: p["governance"]["license"].update(value="Apache-2.0"),
|
||||||
|
"redistribution": lambda p: p["governance"].update(redistribution="allowed"),
|
||||||
|
"stewardship": lambda p: p["governance"].update(stewardship="official"),
|
||||||
|
"maintainer": lambda p: p["governance"]["upstreamMaintainer"].update(name="Other Maintainer"),
|
||||||
|
"supervisor-name": lambda p: p["spec"].update(supervisorName="Other Supervisor"),
|
||||||
|
"supervisor-id": lambda p: p["spec"].update(supervisorAgentId="other-lead"),
|
||||||
|
"member-count": lambda p: p["spec"].update(memberCount=9),
|
||||||
|
"member-names": lambda p: p["spec"].update(memberNames=["Someone Else"]),
|
||||||
|
"required-client-version": lambda p: p["compatibility"].update(requiredClientVersion="10.0.0"),
|
||||||
|
}
|
||||||
|
for name, mutation in mutations.items():
|
||||||
|
with self.subTest(name=name):
|
||||||
|
self.write_team_pointer_case(mutate_sidecar=mutation)
|
||||||
|
self.assertTrue(any(issue.rule == "legacy-consistency" for issue in self.validate().issues))
|
||||||
|
|
||||||
|
def test_team_sidecar_must_not_drop_declared_display_facts(self) -> None:
|
||||||
|
for field in VALIDATOR.TEAM_DISPLAY_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
self.write_team_pointer_case(mutate_sidecar=lambda p, f=field: p["spec"].pop(f))
|
||||||
|
self.assertTrue(any(issue.rule == "legacy-consistency" for issue in self.validate().issues))
|
||||||
|
|
||||||
|
def test_team_sidecar_must_not_synthesize_undeclared_display_facts(self) -> None:
|
||||||
|
for field in VALIDATOR.TEAM_DISPLAY_FIELDS:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
self.write_team_pointer_case(mutate_entry=lambda p, f=field: p.pop(f))
|
||||||
|
self.assertTrue(any(issue.rule == "legacy-consistency" for issue in self.validate().issues))
|
||||||
|
|
||||||
|
def test_accepts_team_pointer_that_declares_no_display_facts(self) -> None:
|
||||||
|
def drop_entry_facts(payload):
|
||||||
|
for field in VALIDATOR.TEAM_DISPLAY_FIELDS:
|
||||||
|
payload.pop(field, None)
|
||||||
|
|
||||||
|
def drop_sidecar_facts(payload):
|
||||||
|
payload["spec"] = {"kind": "team"}
|
||||||
|
|
||||||
|
self.write_team_pointer_case(drop_entry_facts, drop_sidecar_facts)
|
||||||
|
report = self.validate(require_complete=True)
|
||||||
|
self.assertFalse(report.has_errors, report.issues)
|
||||||
|
|
||||||
|
def test_team_pointer_keeps_required_client_version_aligned(self) -> None:
|
||||||
|
self.write_team_pointer_case(
|
||||||
|
lambda p: p.update(requiredClientVersion="10.0.137"),
|
||||||
|
lambda p: p["compatibility"].update(requiredClientVersion="10.0.137"),
|
||||||
|
)
|
||||||
|
self.assertFalse(self.validate(require_complete=True).has_errors)
|
||||||
|
self.write_team_pointer_case(lambda p: p.update(requiredClientVersion="10.0.137"))
|
||||||
|
self.assertTrue(any(issue.rule == "legacy-consistency" for issue in self.validate().issues))
|
||||||
|
|
||||||
|
def test_rejects_invalid_raw_team_entry_even_if_sidecar_repeats_it(self) -> None:
|
||||||
|
cases = [
|
||||||
|
# A team is forked and git-pulled; zip / web cannot express either action.
|
||||||
|
(lambda p: p["source"].update(kind="zip"), lambda p: p["provenance"]["content"].update(kind="zip")),
|
||||||
|
(lambda p: p["source"].update(kind="web"), lambda p: p["provenance"]["content"].update(kind="web")),
|
||||||
|
(lambda p: p["source"].pop("repoUrl"), None),
|
||||||
|
# There is no subpath or byte digest for a whole forked repository.
|
||||||
|
(lambda p: p["source"].update(path="teams/example"), None),
|
||||||
|
(lambda p: p["source"].update(sha256="b" * 64), None),
|
||||||
|
# Teams are always fork-install + git-pull-update; no policy pair exists.
|
||||||
|
(lambda p: p.update(installPolicy="market", updatePolicy="market"), None),
|
||||||
|
(lambda p: p.update(fullDesc="Teams have no fullDesc field"), None),
|
||||||
|
(lambda p: p.update(latestVersion="0.1.0-beta"), lambda p: p["release"].update(version="0.1.0-beta")),
|
||||||
|
(lambda p: p.update(id="-example-team"), None),
|
||||||
|
(lambda p: p.update(memberCount=0), None),
|
||||||
|
(lambda p: p.update(memberNames=[f"Member {index}" for index in range(33)]), None),
|
||||||
|
(lambda p: p.update(requiredSkills=["duplicate", "duplicate"]), None),
|
||||||
|
(lambda p: p.update(supervisorAgentId="not a slug"), None),
|
||||||
|
(lambda p: p.update(requiredClientVersion="10.0.137-rc1"), None),
|
||||||
|
(lambda p: p["maintainer"].update(verified="true"), None),
|
||||||
|
]
|
||||||
|
for index, (entry_change, metadata_change) in enumerate(cases):
|
||||||
|
with self.subTest(index=index):
|
||||||
|
self.write_team_pointer_case(entry_change, metadata_change)
|
||||||
|
self.assertTrue(any(issue.rule == "team-entry-schema" for issue in self.validate().issues))
|
||||||
|
|
||||||
|
def test_rejects_team_pointer_entry_id_that_is_not_the_catalog_slug(self) -> None:
|
||||||
|
self.write_team_pointer_case(
|
||||||
|
mutate_entry=lambda p: p.update(id="other-team"),
|
||||||
|
mutate_sidecar=lambda p: p["identity"].update(id="other-team"),
|
||||||
|
)
|
||||||
|
self.assertTrue(any("entry.id" in issue.message for issue in self.validate().issues))
|
||||||
|
|
||||||
|
def test_team_sidecar_rejects_inline_team_json(self) -> None:
|
||||||
|
entry_path, _ = self.write_team_pointer_case()
|
||||||
|
self.write_json(entry_path.with_name("team.json"), {"id": "example-team"})
|
||||||
|
issues = self.validate().issues
|
||||||
|
self.assertTrue(any(issue.rule == "fixed-sidecar-path" and "forked team repository" in issue.message for issue in issues))
|
||||||
|
|
||||||
|
def test_team_sidecar_requires_entry_json(self) -> None:
|
||||||
|
entry_path, _ = self.write_team_pointer_case()
|
||||||
|
entry_path.unlink()
|
||||||
|
issues = self.validate().issues
|
||||||
|
self.assertTrue(any(issue.rule == "fixed-sidecar-path" and "next to a legacy entry.json" in issue.message for issue in issues))
|
||||||
|
|
||||||
|
def test_installable_team_pointer_needs_immutable_ref_and_governance_evidence(self) -> None:
|
||||||
|
def installable(payload):
|
||||||
|
payload["governance"].update(
|
||||||
|
availability="installable",
|
||||||
|
compliance={"licenseEvidencePath": "LICENSE", "reviewedRef": "a" * 40,
|
||||||
|
"reviewedAt": "2026-08-30", "reviewedBy": "Example Reviewer", "upstreamEndorsed": False},
|
||||||
|
)
|
||||||
|
payload["governance"]["license"]["evidencePath"] = "LICENSE"
|
||||||
|
payload["timestamps"]["reviewedAt"] = {"state": "known", "value": "2026-08-30", "precision": "day"}
|
||||||
|
|
||||||
|
self.write_team_pointer_case(mutate_sidecar=installable)
|
||||||
|
self.assertFalse(self.validate(require_complete=True).has_errors)
|
||||||
|
|
||||||
|
# A moving tag is not a reproducible pin, so it cannot back an installable listing.
|
||||||
|
def tag_ref(payload):
|
||||||
|
payload["source"]["ref"] = "v0.1.0"
|
||||||
|
|
||||||
|
def tag_provenance(payload):
|
||||||
|
installable(payload)
|
||||||
|
payload["provenance"]["content"]["ref"] = "v0.1.0"
|
||||||
|
payload["governance"]["compliance"]["reviewedRef"] = "v0.1.0"
|
||||||
|
|
||||||
|
self.write_team_pointer_case(tag_ref, tag_provenance)
|
||||||
|
messages = [issue.message for issue in self.validate().issues if issue.rule == "installable-evidence"]
|
||||||
|
self.assertTrue(any("installable Team entry.source" in message for message in messages))
|
||||||
|
|
||||||
|
def test_team_sidecar_requires_market_locales(self) -> None:
|
||||||
|
self.write_team_pointer_case(
|
||||||
|
lambda p: p["i18n"].pop("zh-CN"),
|
||||||
|
lambda p: p["presentation"]["i18n"].pop("zh-CN"),
|
||||||
|
)
|
||||||
|
self.assertTrue(any(issue.rule == "i18n" for issue in self.validate().issues))
|
||||||
|
|
||||||
|
def test_manifest_must_count_teams(self) -> None:
|
||||||
|
self.write_team_pointer_case()
|
||||||
|
manifest = json.loads((self.root / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
manifest["stats"].pop("totalTeams")
|
||||||
|
self.write_json(self.root / "manifest.json", manifest)
|
||||||
|
issues = self.validate().issues
|
||||||
|
self.assertTrue(any(issue.rule == "market-stats" and "totalTeams" in issue.message for issue in issues))
|
||||||
|
|
||||||
|
def test_manifest_may_omit_total_teams_without_teams(self) -> None:
|
||||||
|
manifest = json.loads((self.root / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
self.assertNotIn("totalTeams", manifest["stats"])
|
||||||
|
self.assertFalse(any(issue.rule == "market-stats" for issue in self.validate().issues))
|
||||||
|
manifest["stats"]["totalTeams"] = 1
|
||||||
|
self.write_json(self.root / "manifest.json", manifest)
|
||||||
|
self.assertTrue(any(issue.rule == "market-stats" and "totalTeams" in issue.message for issue in self.validate().issues))
|
||||||
|
|
||||||
|
def test_team_license_evidence_clears_the_unverified_warning(self) -> None:
|
||||||
|
# A known license that matches the pointer is fully reconciled: no warning.
|
||||||
|
self.write_team_pointer_case()
|
||||||
|
report = self.validate(require_complete=True)
|
||||||
|
self.assertFalse(report.has_errors, report.issues)
|
||||||
|
self.assertFalse(any(issue.rule == "legacy-license-unverified" for issue in report.issues))
|
||||||
|
|
||||||
|
# Dropping back to unknown is legal but must stay visible as a warning.
|
||||||
|
self.write_team_pointer_case(mutate_sidecar=lambda p: p["governance"].update(license=unknown()))
|
||||||
|
report = self.validate()
|
||||||
|
self.assertFalse(report.has_errors, report.issues)
|
||||||
|
self.assertTrue(any(issue.rule == "legacy-license-unverified" for issue in report.issues))
|
||||||
|
|
||||||
|
def test_license_evidence_on_a_pointer_requires_an_immutable_pin(self) -> None:
|
||||||
|
"""Upstream evidence is unreadable offline, so it must name an exact snapshot."""
|
||||||
|
def with_evidence(payload):
|
||||||
|
payload["governance"]["license"]["evidencePath"] = "LICENSE"
|
||||||
|
|
||||||
|
# Pinned to a full SHA: the claim is falsifiable by anyone who fetches it.
|
||||||
|
self.write_team_pointer_case(mutate_sidecar=with_evidence)
|
||||||
|
self.assertFalse(self.validate(require_complete=True).has_errors)
|
||||||
|
|
||||||
|
# Against a moving branch, nobody can say which tree the claim was about.
|
||||||
|
for ref in ("main", None):
|
||||||
|
with self.subTest(ref=ref):
|
||||||
|
def unpin_entry(payload, value=ref):
|
||||||
|
if value is None:
|
||||||
|
payload["source"].pop("ref")
|
||||||
|
else:
|
||||||
|
payload["source"]["ref"] = value
|
||||||
|
|
||||||
|
def unpin_sidecar(payload, value=ref):
|
||||||
|
with_evidence(payload)
|
||||||
|
if value is None:
|
||||||
|
payload["provenance"]["content"].pop("ref")
|
||||||
|
else:
|
||||||
|
payload["provenance"]["content"]["ref"] = value
|
||||||
|
|
||||||
|
self.write_team_pointer_case(unpin_entry, unpin_sidecar)
|
||||||
|
report = self.validate()
|
||||||
|
flagged = [issue for issue in report.issues if issue.rule == "license-evidence"]
|
||||||
|
self.assertTrue(flagged)
|
||||||
|
# listing-only is allowed to stay unpinned, so this must not be fatal
|
||||||
|
self.assertTrue(all(issue.severity == "warning" for issue in flagged))
|
||||||
|
self.assertFalse(report.has_errors, report.issues)
|
||||||
|
|
||||||
|
def test_license_evidence_on_vendored_content_must_exist_locally(self) -> None:
|
||||||
|
"""Vendored content ships here, so the evidence file is checked directly."""
|
||||||
|
self.entry_path.unlink()
|
||||||
|
self.entry_path.with_name("SKILL.md").write_text(
|
||||||
|
"---\nname: example-skill\ndescription: Example builtin skill.\n"
|
||||||
|
"market:\n category: development\n---\nBody.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
self.rewrite_sidecar(
|
||||||
|
lambda payload: payload["governance"]["license"].update(evidencePath="LICENSE")
|
||||||
|
)
|
||||||
|
issues = [issue for issue in self.validate().issues if issue.rule == "license-evidence"]
|
||||||
|
self.assertTrue(any("does not exist" in issue.message for issue in issues), issues)
|
||||||
|
|
||||||
|
(self.entry_path.parent / "LICENSE").write_text("MIT License\n", encoding="utf-8")
|
||||||
|
self.assertFalse(any(issue.rule == "license-evidence" for issue in self.validate().issues))
|
||||||
|
|
||||||
|
def test_team_requires_sidecar_under_complete_coverage(self) -> None:
|
||||||
|
_, sidecar = self.write_team_pointer_case()
|
||||||
|
sidecar.unlink()
|
||||||
|
self.assertTrue(any(issue.rule == "sidecar-coverage" for issue in self.validate(require_complete=True).issues))
|
||||||
|
|
||||||
def test_accepts_complete_installable_agent_pointer_without_inline_agent(self) -> None:
|
def test_accepts_complete_installable_agent_pointer_without_inline_agent(self) -> None:
|
||||||
entry_path, _ = self.write_agent_pointer_case()
|
entry_path, _ = self.write_agent_pointer_case()
|
||||||
report = self.validate(require_complete=True)
|
report = self.validate(require_complete=True)
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ The sidecar is deliberately discovered at one fixed path next to a legacy
|
|||||||
``agent.json``, ``SKILL.md`` or ``entry.json``. It supplements the legacy
|
``agent.json``, ``SKILL.md`` or ``entry.json``. It supplements the legacy
|
||||||
file; it never selects an arbitrary metadata path and never declares the
|
file; it never selects an arbitrary metadata path and never declares the
|
||||||
trusted catalog provider identity.
|
trusted catalog provider identity.
|
||||||
|
|
||||||
|
Agents, Teams and Skills are the three catalog item kinds. A Team listing has
|
||||||
|
no inline form: it is always ``teams/<id>/entry.json``, a git fork pointer whose
|
||||||
|
installation forks the team repository and whose update is a ``git pull``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -28,6 +32,19 @@ from jsonschema import Draft7Validator, FormatChecker, ValidationError, validato
|
|||||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
SCHEMA_PATH = REPO_ROOT / "schemas" / "catalog-metadata.v1.schema.json"
|
SCHEMA_PATH = REPO_ROOT / "schemas" / "catalog-metadata.v1.schema.json"
|
||||||
AGENT_ENTRY_SCHEMA_NAME = "market-agent-entry.client.schema.json"
|
AGENT_ENTRY_SCHEMA_NAME = "market-agent-entry.client.schema.json"
|
||||||
|
TEAM_ENTRY_SCHEMA_NAME = "market-team-entry.client.schema.json"
|
||||||
|
# Catalog roots that may hold a sidecar, in listing order.
|
||||||
|
CATALOG_ROOTS = ("agents", "teams", "skills")
|
||||||
|
ALLOWED_SIDECAR_LOCATIONS = ", ".join(f"{name}/<id>/" for name in CATALOG_ROOTS)
|
||||||
|
# Team display facts are mirrored in entry.json and the sidecar; neither file may
|
||||||
|
# declare one the other omits, otherwise the two disagree on what is even known.
|
||||||
|
TEAM_DISPLAY_FIELDS = (
|
||||||
|
"supervisorName",
|
||||||
|
"supervisorAgentId",
|
||||||
|
"memberCount",
|
||||||
|
"memberNames",
|
||||||
|
"requiredSkills",
|
||||||
|
)
|
||||||
SIDECAR_NAME = "catalog-metadata.v1.json"
|
SIDECAR_NAME = "catalog-metadata.v1.json"
|
||||||
EXPECTED_SCHEMA_REF = "../../schemas/catalog-metadata.v1.schema.json"
|
EXPECTED_SCHEMA_REF = "../../schemas/catalog-metadata.v1.schema.json"
|
||||||
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
||||||
@@ -42,7 +59,8 @@ def _client_pattern(validator, pattern, instance, schema):
|
|||||||
yield ValidationError(f"{instance!r} does not match {pattern!r}")
|
yield ValidationError(f"{instance!r} does not match {pattern!r}")
|
||||||
|
|
||||||
|
|
||||||
AgentEntryValidator = validators.extend(Draft7Validator, {"pattern": _client_pattern})
|
# The extension is about client pattern semantics; it is shared by every pointer kind.
|
||||||
|
ClientEntryValidator = validators.extend(Draft7Validator, {"pattern": _client_pattern})
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -157,7 +175,78 @@ def _legacy_i18n(raw: Any, *, short_key: str) -> tuple[dict[str, dict[str, Any]]
|
|||||||
return locales, default_locale if isinstance(default_locale, str) else None
|
return locales, default_locale if isinstance(default_locale, str) else None
|
||||||
|
|
||||||
|
|
||||||
def load_legacy(sidecar: Path, report: Report, root: Path, agent_entry_validator) -> LegacyItem | None:
|
def _report_entry_schema_errors(
|
||||||
|
report: Report,
|
||||||
|
legacy_path: Path,
|
||||||
|
root: Path,
|
||||||
|
validator,
|
||||||
|
rule: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""Validate a raw pointer against the exported client contract.
|
||||||
|
|
||||||
|
Returns True when the pointer is already invalid for the client, in which case
|
||||||
|
no sidecar comparison is meaningful: a sidecar must never make an entry.json the
|
||||||
|
client rejects look acceptable.
|
||||||
|
"""
|
||||||
|
errors = list(validator.iter_errors(data))
|
||||||
|
for error in errors:
|
||||||
|
report.add(_rel(legacy_path, root), rule, f"{_json_path(error)}: {error.message}")
|
||||||
|
return bool(errors)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_team_pointer(
|
||||||
|
sidecar: Path,
|
||||||
|
parent: Path,
|
||||||
|
report: Report,
|
||||||
|
root: Path,
|
||||||
|
team_entry_validator,
|
||||||
|
) -> LegacyItem | None:
|
||||||
|
"""Load ``teams/<id>/entry.json``.
|
||||||
|
|
||||||
|
A Team listing is a fork pointer and has no inline form: the team body
|
||||||
|
(team.json / members.json / shared/) lives in the forked repository, so the
|
||||||
|
catalog only ever carries entry.json plus the sidecar.
|
||||||
|
"""
|
||||||
|
pointer_path = parent / "entry.json"
|
||||||
|
inline_path = parent / "team.json"
|
||||||
|
if inline_path.is_file():
|
||||||
|
report.add(
|
||||||
|
_rel(inline_path, root),
|
||||||
|
"fixed-sidecar-path",
|
||||||
|
"team listings are fork pointers; team.json belongs in the forked team repository",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if not pointer_path.is_file():
|
||||||
|
report.add(
|
||||||
|
_rel(sidecar, root),
|
||||||
|
"fixed-sidecar-path",
|
||||||
|
"team sidecar must be next to a legacy entry.json",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
data = _read_json(pointer_path, report, root, "legacy-read")
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
if _report_entry_schema_errors(report, pointer_path, root, team_entry_validator, "team-entry-schema", data):
|
||||||
|
return None
|
||||||
|
if data.get("id") != parent.name:
|
||||||
|
report.add(_rel(pointer_path, root), "legacy-consistency", "entry.id must equal the catalog directory slug")
|
||||||
|
i18n, default_locale = _legacy_i18n(data.get("i18n"), short_key="shortDesc")
|
||||||
|
return LegacyItem(
|
||||||
|
kind="team",
|
||||||
|
item_id=parent.name,
|
||||||
|
source_type="pointer",
|
||||||
|
data=data,
|
||||||
|
i18n=i18n,
|
||||||
|
default_locale=default_locale,
|
||||||
|
# A Team pointer carries no installed version; the catalog registers latestVersion.
|
||||||
|
version=data.get("latestVersion"),
|
||||||
|
category=data.get("category") if isinstance(data.get("category"), str) else None,
|
||||||
|
tags=data.get("tags") if isinstance(data.get("tags"), list) else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_legacy(sidecar: Path, report: Report, root: Path, entry_validators: dict[str, Any]) -> LegacyItem | None:
|
||||||
parent = sidecar.parent
|
parent = sidecar.parent
|
||||||
root_kind = parent.parent.name
|
root_kind = parent.parent.name
|
||||||
if root_kind == "agents":
|
if root_kind == "agents":
|
||||||
@@ -175,12 +264,10 @@ def load_legacy(sidecar: Path, report: Report, root: Path, agent_entry_validator
|
|||||||
data = _read_json(legacy_path, report, root, "legacy-read")
|
data = _read_json(legacy_path, report, root, "legacy-read")
|
||||||
if data is None:
|
if data is None:
|
||||||
return None
|
return None
|
||||||
if is_pointer:
|
if is_pointer and _report_entry_schema_errors(
|
||||||
errors = list(agent_entry_validator.iter_errors(data))
|
report, legacy_path, root, entry_validators["agent"], "agent-entry-schema", data
|
||||||
for error in errors:
|
):
|
||||||
report.add(_rel(legacy_path, root), "agent-entry-schema", f"{_json_path(error)}: {error.message}")
|
return None
|
||||||
if errors:
|
|
||||||
return None
|
|
||||||
if is_pointer and data.get("id") != parent.name:
|
if is_pointer and data.get("id") != parent.name:
|
||||||
report.add(_rel(legacy_path, root), "legacy-consistency", "entry.id must equal the catalog directory slug")
|
report.add(_rel(legacy_path, root), "legacy-consistency", "entry.id must equal the catalog directory slug")
|
||||||
# The pointer ID is a listing slug, not the upstream AgentFS UUID.
|
# The pointer ID is a listing slug, not the upstream AgentFS UUID.
|
||||||
@@ -201,11 +288,14 @@ def load_legacy(sidecar: Path, report: Report, root: Path, agent_entry_validator
|
|||||||
tags=data.get("tags") if isinstance(data.get("tags"), list) else None,
|
tags=data.get("tags") if isinstance(data.get("tags"), list) else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if root_kind == "teams":
|
||||||
|
return _load_team_pointer(sidecar, parent, report, root, entry_validators["team"])
|
||||||
|
|
||||||
if root_kind != "skills":
|
if root_kind != "skills":
|
||||||
report.add(
|
report.add(
|
||||||
_rel(sidecar, root),
|
_rel(sidecar, root),
|
||||||
"fixed-sidecar-path",
|
"fixed-sidecar-path",
|
||||||
f"{SIDECAR_NAME} is only allowed at agents/<id>/ or skills/<id>/",
|
f"{SIDECAR_NAME} is only allowed at {ALLOWED_SIDECAR_LOCATIONS}",
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -361,7 +451,7 @@ def _validate_content_consistency(
|
|||||||
}
|
}
|
||||||
for sidecar_key, legacy_key in mapping.items():
|
for sidecar_key, legacy_key in mapping.items():
|
||||||
legacy_value = source.get(legacy_key)
|
legacy_value = source.get(legacy_key)
|
||||||
if legacy.kind == "agent":
|
if legacy.kind in {"agent", "team"}:
|
||||||
declared_value = content.get(sidecar_key)
|
declared_value = content.get(sidecar_key)
|
||||||
# A sidecar must describe the exact pointer that the client installs,
|
# A sidecar must describe the exact pointer that the client installs,
|
||||||
# not add a different subdirectory or pin that entry.json never uses.
|
# not add a different subdirectory or pin that entry.json never uses.
|
||||||
@@ -369,7 +459,8 @@ def _validate_content_consistency(
|
|||||||
declared_value = None if declared_value == "" else declared_value
|
declared_value = None if declared_value == "" else declared_value
|
||||||
legacy_value = None if legacy_value == "" else legacy_value
|
legacy_value = None if legacy_value == "" else legacy_value
|
||||||
if declared_value != legacy_value:
|
if declared_value != legacy_value:
|
||||||
report.add(rel, "legacy-consistency", f"provenance.content.{sidecar_key} differs from the Agent pointer")
|
label = "Agent" if legacy.kind == "agent" else "Team"
|
||||||
|
report.add(rel, "legacy-consistency", f"provenance.content.{sidecar_key} differs from the {label} pointer")
|
||||||
elif legacy_value is not None:
|
elif legacy_value is not None:
|
||||||
_compare(
|
_compare(
|
||||||
report,
|
report,
|
||||||
@@ -426,6 +517,77 @@ def _validate_governance_consistency(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_license_evidence(
|
||||||
|
report: Report,
|
||||||
|
rel: str,
|
||||||
|
sidecar: dict[str, Any],
|
||||||
|
legacy: LegacyItem,
|
||||||
|
item_dir: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Make ``evidencePath`` a checkable claim rather than a well-formed string.
|
||||||
|
|
||||||
|
The schema only constrains the shape, not what the path is relative to, and the
|
||||||
|
two readings disagree exactly where it matters:
|
||||||
|
|
||||||
|
* vendored content (builtin Skills, inline Agents) ships inside this repository,
|
||||||
|
so the evidence file must be present here and is checked directly;
|
||||||
|
* a pointer distributes nothing, so its evidence can only live in the upstream
|
||||||
|
snapshot. This validator cannot read that offline, so an unpinned pointer is
|
||||||
|
flagged instead: the claim is about whatever HEAD happens to be and nobody can
|
||||||
|
check it. That stays a warning, because an installable pointer is already
|
||||||
|
required to be pinned by ``installable-evidence`` — this branch is only ever
|
||||||
|
reached by a listing-only entry, which is explicitly allowed to be unpinned and
|
||||||
|
installs nothing on the strength of the claim.
|
||||||
|
"""
|
||||||
|
governance = sidecar.get("governance")
|
||||||
|
if not isinstance(governance, dict):
|
||||||
|
return
|
||||||
|
license_fact = governance.get("license")
|
||||||
|
compliance = governance.get("compliance")
|
||||||
|
candidates: list[tuple[str, Any]] = []
|
||||||
|
if isinstance(license_fact, dict):
|
||||||
|
candidates.append(("license.evidencePath", license_fact.get("evidencePath")))
|
||||||
|
if isinstance(compliance, dict):
|
||||||
|
for key in ("licenseEvidencePath", "noticePath"):
|
||||||
|
candidates.append((f"compliance.{key}", compliance.get(key)))
|
||||||
|
|
||||||
|
pinned = _content_is_immutable((sidecar.get("provenance") or {}).get("content"))
|
||||||
|
for field, value in candidates:
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
continue
|
||||||
|
if legacy.source_type == "pointer":
|
||||||
|
if not pinned:
|
||||||
|
report.add(
|
||||||
|
rel,
|
||||||
|
"license-evidence",
|
||||||
|
f"{field} names evidence inside the upstream snapshot but provenance.content is not "
|
||||||
|
"pinned to an immutable ref or digest, so the claim is about a moving target and "
|
||||||
|
"cannot be checked",
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
elif not (item_dir / value).is_file():
|
||||||
|
report.add(
|
||||||
|
rel,
|
||||||
|
"license-evidence",
|
||||||
|
f"{field} {value!r} does not exist in the catalog item directory",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_team_spec(report: Report, rel: str, sidecar: dict[str, Any], legacy: LegacyItem) -> None:
|
||||||
|
"""Keep the sidecar Team facts identical to the entry.json Team facts.
|
||||||
|
|
||||||
|
The comparison is symmetric on purpose: a sidecar must not synthesize a
|
||||||
|
supervisor, member count or required-Skill list that the pointer never
|
||||||
|
declared, and it must not drop one the pointer did declare.
|
||||||
|
"""
|
||||||
|
spec = sidecar.get("spec")
|
||||||
|
if not isinstance(spec, dict):
|
||||||
|
return
|
||||||
|
for field in TEAM_DISPLAY_FIELDS:
|
||||||
|
if spec.get(field) != legacy.data.get(field):
|
||||||
|
report.add(rel, "legacy-consistency", f"spec.{field} differs from the Team pointer")
|
||||||
|
|
||||||
|
|
||||||
def _validate_collection(
|
def _validate_collection(
|
||||||
report: Report,
|
report: Report,
|
||||||
rel: str,
|
rel: str,
|
||||||
@@ -578,7 +740,7 @@ def validate_sidecar(
|
|||||||
report: Report,
|
report: Report,
|
||||||
root: Path,
|
root: Path,
|
||||||
supported_locales: set[str],
|
supported_locales: set[str],
|
||||||
agent_entry_validator,
|
entry_validators: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
rel = _rel(sidecar_path, root)
|
rel = _rel(sidecar_path, root)
|
||||||
sidecar = _read_json(sidecar_path, report, root, "catalog-schema")
|
sidecar = _read_json(sidecar_path, report, root, "catalog-schema")
|
||||||
@@ -589,7 +751,7 @@ def validate_sidecar(
|
|||||||
if any(issue.path == rel and issue.rule == "catalog-schema" for issue in report.issues):
|
if any(issue.path == rel and issue.rule == "catalog-schema" for issue in report.issues):
|
||||||
return
|
return
|
||||||
|
|
||||||
legacy = load_legacy(sidecar_path, report, root, agent_entry_validator)
|
legacy = load_legacy(sidecar_path, report, root, entry_validators)
|
||||||
if legacy is None:
|
if legacy is None:
|
||||||
return
|
return
|
||||||
identity = sidecar["identity"]
|
identity = sidecar["identity"]
|
||||||
@@ -640,8 +802,19 @@ def validate_sidecar(
|
|||||||
sidecar["compatibility"].get("requiredClientVersion"), legacy.data.get("requiredClientVersion"),
|
sidecar["compatibility"].get("requiredClientVersion"), legacy.data.get("requiredClientVersion"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if legacy.kind == "team":
|
||||||
|
# Teams are always fork-installed and git-pull-updated, so there is no
|
||||||
|
# policy pair to reconcile; the display facts and the client floor are.
|
||||||
|
_validate_team_spec(report, rel, sidecar, legacy)
|
||||||
|
# Symmetric on purpose: the upgrade gate is enforced from entry.json, which is
|
||||||
|
# what the client reads. A floor that exists only in the sidecar would let a
|
||||||
|
# listing look version-gated while the client still offers it for install.
|
||||||
|
if sidecar["compatibility"].get("requiredClientVersion") != legacy.data.get("requiredClientVersion"):
|
||||||
|
report.add(rel, "legacy-consistency", "compatibility.requiredClientVersion differs from the Team pointer")
|
||||||
|
|
||||||
_validate_content_consistency(report, rel, sidecar, legacy)
|
_validate_content_consistency(report, rel, sidecar, legacy)
|
||||||
_validate_governance_consistency(report, rel, sidecar, legacy)
|
_validate_governance_consistency(report, rel, sidecar, legacy)
|
||||||
|
_validate_license_evidence(report, rel, sidecar, legacy, sidecar_path.parent)
|
||||||
_validate_collection(report, rel, sidecar, legacy, supported_locales)
|
_validate_collection(report, rel, sidecar, legacy, supported_locales)
|
||||||
|
|
||||||
governance = sidecar.get("governance")
|
governance = sidecar.get("governance")
|
||||||
@@ -690,11 +863,12 @@ def validate_sidecar(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if legacy.kind == "agent" and legacy.source_type == "pointer":
|
if legacy.kind in {"agent", "team"} and legacy.source_type == "pointer":
|
||||||
if not _content_is_immutable(legacy.data.get("source")):
|
if not _content_is_immutable(legacy.data.get("source")):
|
||||||
|
label = "Agent" if legacy.kind == "agent" else "Team"
|
||||||
report.add(
|
report.add(
|
||||||
rel, "installable-evidence",
|
rel, "installable-evidence",
|
||||||
"installable Agent entry.source must itself declare an immutable ref or SHA-256 digest",
|
f"installable {label} entry.source must itself declare an immutable ref or SHA-256 digest",
|
||||||
)
|
)
|
||||||
|
|
||||||
if not _content_is_immutable(content):
|
if not _content_is_immutable(content):
|
||||||
@@ -734,6 +908,8 @@ def _validate_stats(root: Path, report: Report, sidecars: Iterable[Path]) -> Non
|
|||||||
agent_count = sum(1 for _ in (root / "agents").glob("*/agent.json")) + sum(
|
agent_count = sum(1 for _ in (root / "agents").glob("*/agent.json")) + sum(
|
||||||
1 for _ in (root / "agents").glob("*/entry.json")
|
1 for _ in (root / "agents").glob("*/entry.json")
|
||||||
)
|
)
|
||||||
|
# A Team has no inline form, so entry.json alone is the publishable unit.
|
||||||
|
team_count = sum(1 for _ in (root / "teams").glob("*/entry.json"))
|
||||||
builtin_count = sum(1 for _ in (root / "skills").glob("*/SKILL.md"))
|
builtin_count = sum(1 for _ in (root / "skills").glob("*/SKILL.md"))
|
||||||
pointer_count = sum(1 for _ in (root / "skills").glob("*/entry.json"))
|
pointer_count = sum(1 for _ in (root / "skills").glob("*/entry.json"))
|
||||||
collection_count = 0
|
collection_count = 0
|
||||||
@@ -750,6 +926,7 @@ def _validate_stats(root: Path, report: Report, sidecars: Iterable[Path]) -> Non
|
|||||||
sidecar_list = list(sidecars)
|
sidecar_list = list(sidecars)
|
||||||
report.stats = {
|
report.stats = {
|
||||||
"agents": agent_count,
|
"agents": agent_count,
|
||||||
|
"teams": team_count,
|
||||||
"builtinSkills": builtin_count,
|
"builtinSkills": builtin_count,
|
||||||
"pointerSkills": pointer_count,
|
"pointerSkills": pointer_count,
|
||||||
"publishableSkills": builtin_count + pointer_count,
|
"publishableSkills": builtin_count + pointer_count,
|
||||||
@@ -767,6 +944,11 @@ def _validate_stats(root: Path, report: Report, sidecars: Iterable[Path]) -> Non
|
|||||||
report.add("manifest.json", "market-stats", f"stats.totalAgents must be {agent_count}")
|
report.add("manifest.json", "market-stats", f"stats.totalAgents must be {agent_count}")
|
||||||
if stats.get("totalSkills") != builtin_count + pointer_count:
|
if stats.get("totalSkills") != builtin_count + pointer_count:
|
||||||
report.add("manifest.json", "market-stats", f"stats.totalSkills must be {builtin_count + pointer_count}")
|
report.add("manifest.json", "market-stats", f"stats.totalSkills must be {builtin_count + pointer_count}")
|
||||||
|
# The client stats schema keeps totalTeams optional so a catalog with no teams
|
||||||
|
# stays valid; once a team exists, or the key is declared at all, it must be exact.
|
||||||
|
declared_teams = stats.get("totalTeams")
|
||||||
|
if (team_count > 0 or declared_teams is not None) and declared_teams != team_count:
|
||||||
|
report.add("manifest.json", "market-stats", f"stats.totalTeams must be {team_count}")
|
||||||
|
|
||||||
|
|
||||||
def validate_repository(root: Path = REPO_ROOT, *, require_complete: bool = False) -> Report:
|
def validate_repository(root: Path = REPO_ROOT, *, require_complete: bool = False) -> Report:
|
||||||
@@ -781,32 +963,42 @@ def validate_repository(root: Path = REPO_ROOT, *, require_complete: bool = Fals
|
|||||||
except Exception as exc: # jsonschema raises several SchemaError subclasses
|
except Exception as exc: # jsonschema raises several SchemaError subclasses
|
||||||
report.add(_rel(schema_path, root), "catalog-schema", f"invalid JSON Schema: {exc}")
|
report.add(_rel(schema_path, root), "catalog-schema", f"invalid JSON Schema: {exc}")
|
||||||
return report
|
return report
|
||||||
agent_entry_schema = _read_json(root / "schemas" / AGENT_ENTRY_SCHEMA_NAME, report, root, "agent-entry-schema")
|
entry_validators: dict[str, Any] = {}
|
||||||
if agent_entry_schema is None:
|
for kind, schema_name, rule in (
|
||||||
return report
|
("agent", AGENT_ENTRY_SCHEMA_NAME, "agent-entry-schema"),
|
||||||
try:
|
("team", TEAM_ENTRY_SCHEMA_NAME, "team-entry-schema"),
|
||||||
AgentEntryValidator.check_schema(agent_entry_schema)
|
):
|
||||||
except Exception as exc:
|
entry_schema = _read_json(root / "schemas" / schema_name, report, root, rule)
|
||||||
report.add(f"schemas/{AGENT_ENTRY_SCHEMA_NAME}", "agent-entry-schema", f"invalid JSON Schema: {exc}")
|
if entry_schema is None:
|
||||||
return report
|
return report
|
||||||
agent_entry_validator = AgentEntryValidator(agent_entry_schema, format_checker=FormatChecker())
|
try:
|
||||||
|
ClientEntryValidator.check_schema(entry_schema)
|
||||||
|
except Exception as exc:
|
||||||
|
report.add(f"schemas/{schema_name}", rule, f"invalid JSON Schema: {exc}")
|
||||||
|
return report
|
||||||
|
entry_validators[kind] = ClientEntryValidator(entry_schema, format_checker=FormatChecker())
|
||||||
validator = Draft7Validator(schema, format_checker=FormatChecker())
|
validator = Draft7Validator(schema, format_checker=FormatChecker())
|
||||||
sidecars = _discover_sidecars(root)
|
sidecars = _discover_sidecars(root)
|
||||||
supported_locales = _load_supported_locales(root, report)
|
supported_locales = _load_supported_locales(root, report)
|
||||||
|
|
||||||
|
catalog_roots = {root / name for name in CATALOG_ROOTS}
|
||||||
for sidecar in sidecars:
|
for sidecar in sidecars:
|
||||||
if sidecar.parent.parent not in {root / "agents", root / "skills"}:
|
if sidecar.parent.parent not in catalog_roots:
|
||||||
report.add(
|
report.add(
|
||||||
_rel(sidecar, root),
|
_rel(sidecar, root),
|
||||||
"fixed-sidecar-path",
|
"fixed-sidecar-path",
|
||||||
f"{SIDECAR_NAME} is only allowed at agents/<id>/ or skills/<id>/",
|
f"{SIDECAR_NAME} is only allowed at {ALLOWED_SIDECAR_LOCATIONS}",
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
validate_sidecar(sidecar, validator, report, root, supported_locales, agent_entry_validator)
|
validate_sidecar(sidecar, validator, report, root, supported_locales, entry_validators)
|
||||||
|
|
||||||
_validate_stats(root, report, sidecars)
|
_validate_stats(root, report, sidecars)
|
||||||
if require_complete:
|
if require_complete:
|
||||||
expected = report.stats.get("agents", 0) + report.stats.get("publishableSkills", 0)
|
expected = (
|
||||||
|
report.stats.get("agents", 0)
|
||||||
|
+ report.stats.get("teams", 0)
|
||||||
|
+ report.stats.get("publishableSkills", 0)
|
||||||
|
)
|
||||||
actual = report.stats.get("sidecars", 0)
|
actual = report.stats.get("sidecars", 0)
|
||||||
if actual != expected:
|
if actual != expected:
|
||||||
report.add(
|
report.add(
|
||||||
@@ -823,7 +1015,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--require-complete",
|
"--require-complete",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="require one sidecar for every publishable top-level Agent and Skill",
|
help="require one sidecar for every publishable top-level Agent, Team and Skill",
|
||||||
)
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
report = validate_repository(require_complete=args.require_complete)
|
report = validate_repository(require_complete=args.require_complete)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -102,5 +103,111 @@ class PublishableAgentCatalogTests(unittest.TestCase):
|
|||||||
self.assertEqual(["pointer-agent"], pointers)
|
self.assertEqual(["pointer-agent"], pointers)
|
||||||
|
|
||||||
|
|
||||||
|
class EntryIconPolicyTests(unittest.TestCase):
|
||||||
|
"""`icon` is only required where the client actually renders one.
|
||||||
|
|
||||||
|
`marketSkillSchema` exposes `icon`; `marketAgentSchema` and `marketTeamSchema`
|
||||||
|
expose `avatar` and have no `icon` property at all, so an icon on those listings
|
||||||
|
can never reach a card.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/></svg>'
|
||||||
|
|
||||||
|
def check(self, kind: str, *, icon: object = ..., source=None, source_kinds=None) -> list[object]:
|
||||||
|
entry = {
|
||||||
|
"id": "example",
|
||||||
|
"name": "Example",
|
||||||
|
"category": "development",
|
||||||
|
"maintainer": {"name": "Example Maintainer", "verified": False},
|
||||||
|
"stewardship": "community",
|
||||||
|
"license": "MIT",
|
||||||
|
"redistribution": "allowed",
|
||||||
|
"source": source or {"kind": "git", "repoUrl": "https://example.com/example.git"},
|
||||||
|
}
|
||||||
|
if icon is not ...:
|
||||||
|
entry["icon"] = icon
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
entry_dir = root / f"{kind}s" / "example"
|
||||||
|
entry_dir.mkdir(parents=True)
|
||||||
|
entry_file = entry_dir / "entry.json"
|
||||||
|
entry_file.write_text(json.dumps(entry), encoding="utf-8")
|
||||||
|
report = VALIDATOR.Report()
|
||||||
|
with patch.object(VALIDATOR, "REPO_ROOT", root):
|
||||||
|
VALIDATOR.validate_entry_json(
|
||||||
|
report, entry_file, {"development"}, False, kind,
|
||||||
|
source_kinds or frozenset({"git", "web", "zip"}),
|
||||||
|
)
|
||||||
|
return report.issues
|
||||||
|
|
||||||
|
def test_skill_entry_still_requires_an_icon(self) -> None:
|
||||||
|
messages = [issue.message for issue in self.check("skill")]
|
||||||
|
self.assertTrue(any("missing required field 'icon'" in message for message in messages))
|
||||||
|
|
||||||
|
def test_team_and_agent_entries_may_omit_the_icon(self) -> None:
|
||||||
|
for kind in ("team", "agent"):
|
||||||
|
with self.subTest(kind=kind):
|
||||||
|
self.assertEqual([], self.check(kind))
|
||||||
|
|
||||||
|
def test_icon_on_a_team_listing_is_flagged_but_not_fatal(self) -> None:
|
||||||
|
issues = self.check("team", icon=self.ICON)
|
||||||
|
self.assertEqual(1, len(issues))
|
||||||
|
self.assertEqual("warning", issues[0].severity)
|
||||||
|
self.assertIn("dead weight", issues[0].message)
|
||||||
|
|
||||||
|
def test_a_declared_icon_is_still_validated_on_every_kind(self) -> None:
|
||||||
|
for kind in ("skill", "team"):
|
||||||
|
with self.subTest(kind=kind):
|
||||||
|
errors = [i.message for i in self.check(kind, icon="not svg") if i.severity == "error"]
|
||||||
|
self.assertTrue(any("valid SVG XML" in message for message in errors))
|
||||||
|
|
||||||
|
def test_team_entry_rejects_non_git_sources(self) -> None:
|
||||||
|
# Installing a team forks its repository and updating it is a git pull;
|
||||||
|
# neither action can be expressed by a zip or a fetched web page.
|
||||||
|
for source_kind in ("zip", "web"):
|
||||||
|
with self.subTest(source_kind=source_kind):
|
||||||
|
messages = [
|
||||||
|
issue.message
|
||||||
|
for issue in self.check(
|
||||||
|
"team",
|
||||||
|
source={"kind": source_kind, "repoUrl": "https://example.com/example.zip"},
|
||||||
|
source_kinds=frozenset({"git"}),
|
||||||
|
)
|
||||||
|
if issue.severity == "error"
|
||||||
|
]
|
||||||
|
self.assertTrue(any("must be one of git" in message for message in messages), messages)
|
||||||
|
|
||||||
|
def test_skill_entry_still_accepts_zip_and_web_sources(self) -> None:
|
||||||
|
for source_kind in ("zip", "web"):
|
||||||
|
with self.subTest(source_kind=source_kind):
|
||||||
|
issues = self.check(
|
||||||
|
"skill",
|
||||||
|
icon=self.ICON,
|
||||||
|
source={"kind": source_kind, "repoUrl": "https://example.com/example.zip"},
|
||||||
|
)
|
||||||
|
self.assertEqual([], issues)
|
||||||
|
|
||||||
|
|
||||||
|
class PublishableTeamCatalogTests(unittest.TestCase):
|
||||||
|
def test_counts_team_pointers_only(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp)
|
||||||
|
(root / "teams" / "pointer-team").mkdir(parents=True)
|
||||||
|
(root / "teams" / "pointer-team" / "entry.json").write_text("{}")
|
||||||
|
# A team is a fork pointer: a stray inline body is not a publishable unit.
|
||||||
|
(root / "teams" / "inline-team").mkdir(parents=True)
|
||||||
|
(root / "teams" / "inline-team" / "team.json").write_text("{}")
|
||||||
|
|
||||||
|
with patch.object(VALIDATOR, "REPO_ROOT", root):
|
||||||
|
teams = VALIDATOR.count_publishable_teams()
|
||||||
|
|
||||||
|
self.assertEqual(["pointer-team"], teams)
|
||||||
|
|
||||||
|
def test_returns_empty_without_a_teams_directory(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
with patch.object(VALIDATOR, "REPO_ROOT", Path(tmp)):
|
||||||
|
self.assertEqual([], VALIDATOR.count_publishable_teams())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ Checks:
|
|||||||
6. Frontmatter parses cleanly; heading count of locale body matches source body (+/- 0).
|
6. Frontmatter parses cleanly; heading count of locale body matches source body (+/- 0).
|
||||||
7. categories.json's per-category i18n covers all locales declared in manifest.json.
|
7. categories.json's per-category i18n covers all locales declared in manifest.json.
|
||||||
8. Top-level description is 1-1024 chars (spec); top-level name is 1-64 chars (spec).
|
8. Top-level description is 1-1024 chars (spec); top-level name is 1-64 chars (spec).
|
||||||
9. Skill/Agent counts and builtin skill index match the repository contents.
|
9. Skill/Agent/Team counts and builtin skill index match the repository contents.
|
||||||
10. Skill, Agent, and entry.json category references exist in categories.json.
|
10. Skill, Agent, Team, and entry.json category references exist in categories.json.
|
||||||
11. entry.json pointers have the required marketplace fields, valid inline SVG icons, and safe source URLs.
|
11. entry.json pointers have the required marketplace fields, valid inline SVG icons, and safe source URLs.
|
||||||
|
Team pointers additionally accept only git sources, because a team is installed by forking
|
||||||
|
its repository and updated with git pull.
|
||||||
12. Market Skills set `disable-model-invocation` to true or omit it; false is prohibited.
|
12. Market Skills set `disable-model-invocation` to true or omit it; false is prohibited.
|
||||||
13. Every catalog-metadata.v1.json sidecar passes the strict schema and legacy consistency checks.
|
13. Every catalog-metadata.v1.json sidecar passes the strict schema and legacy consistency checks.
|
||||||
|
|
||||||
@@ -67,6 +69,12 @@ LOCALE_HEADER_PATTERN = re.compile(r"^<!--\s*locale:\s*([a-zA-Z-]+)\s*-->")
|
|||||||
HEADING_PATTERN = re.compile(r"^(#{1,6})\s+\S", re.MULTILINE)
|
HEADING_PATTERN = re.compile(r"^(#{1,6})\s+\S", re.MULTILINE)
|
||||||
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)$", re.DOTALL)
|
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)$", re.DOTALL)
|
||||||
SAFE_URL_PATTERN = re.compile(r"^https://")
|
SAFE_URL_PATTERN = re.compile(r"^https://")
|
||||||
|
# Only Skill cards are rendered from an inline SVG icon. The client's runtime
|
||||||
|
# projections `marketAgentSchema` and `marketTeamSchema` have no `icon` property at
|
||||||
|
# all and require `avatar` instead, so an icon on an Agent or Team listing is never
|
||||||
|
# displayed. Requiring one there would force publishers to ship a field that cannot
|
||||||
|
# take effect, so it is optional for those kinds and flagged when present.
|
||||||
|
ICON_RENDERED_KINDS = frozenset({"skill"})
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -362,6 +370,15 @@ def count_publishable_agents() -> tuple[list[str], list[str]]:
|
|||||||
return agent_json_names, entry_names
|
return agent_json_names, entry_names
|
||||||
|
|
||||||
|
|
||||||
|
def count_publishable_teams() -> list[str]:
|
||||||
|
"""Return Team IDs represented by the Market catalog.
|
||||||
|
|
||||||
|
A Team listing has no inline form: the team body lives in the forked
|
||||||
|
repository, so ``teams/<id>/entry.json`` is the only publishable unit.
|
||||||
|
"""
|
||||||
|
return sorted(p.parent.name for p in (REPO_ROOT / "teams").glob("*/entry.json"))
|
||||||
|
|
||||||
|
|
||||||
def validate_builtin_skills(report: Report, skill_md_names: list[str]) -> None:
|
def validate_builtin_skills(report: Report, skill_md_names: list[str]) -> None:
|
||||||
builtin_path = REPO_ROOT / "builtin-skills.json"
|
builtin_path = REPO_ROOT / "builtin-skills.json"
|
||||||
builtin = load_json(builtin_path, report, "builtin-skills")
|
builtin = load_json(builtin_path, report, "builtin-skills")
|
||||||
@@ -448,13 +465,22 @@ def validate_agent_json(report: Report, agent_file: Path, category_ids: set[str]
|
|||||||
report.add(Issue(rel, "agent-json", f"category '{category}' is not declared in categories.json"))
|
report.add(Issue(rel, "agent-json", f"category '{category}' is not declared in categories.json"))
|
||||||
|
|
||||||
|
|
||||||
def validate_entry_json(report: Report, entry_file: Path, category_ids: set[str], online: bool) -> None:
|
def validate_entry_json(
|
||||||
|
report: Report,
|
||||||
|
entry_file: Path,
|
||||||
|
category_ids: set[str],
|
||||||
|
online: bool,
|
||||||
|
kind: str = "skill",
|
||||||
|
allowed_source_kinds: frozenset[str] = frozenset({"git", "web", "zip"}),
|
||||||
|
) -> None:
|
||||||
rel = entry_file.relative_to(REPO_ROOT).as_posix()
|
rel = entry_file.relative_to(REPO_ROOT).as_posix()
|
||||||
entry = load_json(entry_file, report, "entry-json")
|
entry = load_json(entry_file, report, "entry-json")
|
||||||
if not entry:
|
if not entry:
|
||||||
return
|
return
|
||||||
|
|
||||||
required = ("id", "name", "category", "icon", "maintainer", "stewardship", "license", "redistribution", "source")
|
required = ["id", "name", "category", "maintainer", "stewardship", "license", "redistribution", "source"]
|
||||||
|
if kind in ICON_RENDERED_KINDS:
|
||||||
|
required.append("icon")
|
||||||
for key in required:
|
for key in required:
|
||||||
if key not in entry:
|
if key not in entry:
|
||||||
report.add(Issue(rel, "entry-json", f"missing required field '{key}'"))
|
report.add(Issue(rel, "entry-json", f"missing required field '{key}'"))
|
||||||
@@ -467,17 +493,28 @@ def validate_entry_json(report: Report, entry_file: Path, category_ids: set[str]
|
|||||||
report.add(Issue(rel, "entry-json", f"category '{category}' is not declared in categories.json"))
|
report.add(Issue(rel, "entry-json", f"category '{category}' is not declared in categories.json"))
|
||||||
|
|
||||||
icon = entry.get("icon")
|
icon = entry.get("icon")
|
||||||
if not isinstance(icon, str) or not icon.strip():
|
icon_rendered = kind in ICON_RENDERED_KINDS
|
||||||
report.add(Issue(rel, "entry-json", "icon must be a non-empty inline SVG string"))
|
# An omitted icon is only a problem where the client actually renders one.
|
||||||
else:
|
if icon is not None or icon_rendered:
|
||||||
try:
|
if not isinstance(icon, str) or not icon.strip():
|
||||||
root = ElementTree.fromstring(icon)
|
report.add(Issue(rel, "entry-json", "icon must be a non-empty inline SVG string"))
|
||||||
if root.tag != "{http://www.w3.org/2000/svg}svg":
|
else:
|
||||||
report.add(Issue(rel, "entry-json", "icon root element must be svg in the SVG namespace"))
|
if not icon_rendered:
|
||||||
elif not root.get("viewBox"):
|
report.add(Issue(
|
||||||
report.add(Issue(rel, "entry-json", "icon SVG must declare a viewBox"))
|
rel, "entry-json",
|
||||||
except ElementTree.ParseError as exc:
|
f"icon is dead weight for a {kind} listing: the client renders {kind} cards from "
|
||||||
report.add(Issue(rel, "entry-json", f"icon must be valid SVG XML: {exc}"))
|
"avatar and its runtime projection has no icon field; remove it, or the next "
|
||||||
|
"maintainer will believe editing it changes the card",
|
||||||
|
severity="warning",
|
||||||
|
))
|
||||||
|
try:
|
||||||
|
root = ElementTree.fromstring(icon)
|
||||||
|
if root.tag != "{http://www.w3.org/2000/svg}svg":
|
||||||
|
report.add(Issue(rel, "entry-json", "icon root element must be svg in the SVG namespace"))
|
||||||
|
elif not root.get("viewBox"):
|
||||||
|
report.add(Issue(rel, "entry-json", "icon SVG must declare a viewBox"))
|
||||||
|
except ElementTree.ParseError as exc:
|
||||||
|
report.add(Issue(rel, "entry-json", f"icon must be valid SVG XML: {exc}"))
|
||||||
|
|
||||||
maintainer = entry.get("maintainer")
|
maintainer = entry.get("maintainer")
|
||||||
if not isinstance(maintainer, dict) or not isinstance(maintainer.get("name"), str):
|
if not isinstance(maintainer, dict) or not isinstance(maintainer.get("name"), str):
|
||||||
@@ -494,9 +531,13 @@ def validate_entry_json(report: Report, entry_file: Path, category_ids: set[str]
|
|||||||
if not isinstance(source, dict):
|
if not isinstance(source, dict):
|
||||||
report.add(Issue(rel, "entry-json", "source must be an object"))
|
report.add(Issue(rel, "entry-json", "source must be an object"))
|
||||||
return
|
return
|
||||||
kind = source.get("kind")
|
# 刻意不叫 `kind`:那是本函数的参数,表示条目种类(agent/skill/team)。
|
||||||
if kind not in {"git", "web", "zip"}:
|
# 这里是 source 的传输种类(git/web/zip),两者取值空间不相交,遮蔽掉参数会让
|
||||||
report.add(Issue(rel, "entry-json", f"source.kind '{kind}' must be one of git/web/zip"))
|
# 后续任何一处引用 `kind` 都变成静默取错值。
|
||||||
|
source_kind = source.get("kind")
|
||||||
|
if source_kind not in allowed_source_kinds:
|
||||||
|
allowed = "/".join(sorted(allowed_source_kinds))
|
||||||
|
report.add(Issue(rel, "entry-json", f"source.kind '{source_kind}' must be one of {allowed}"))
|
||||||
repo_url = source.get("repoUrl")
|
repo_url = source.get("repoUrl")
|
||||||
if not isinstance(repo_url, str) or not repo_url.strip():
|
if not isinstance(repo_url, str) or not repo_url.strip():
|
||||||
report.add(Issue(rel, "entry-json", "source.repoUrl is required"))
|
report.add(Issue(rel, "entry-json", "source.repoUrl is required"))
|
||||||
@@ -530,6 +571,8 @@ def validate_market_catalog(report: Report, manifest: dict[str, Any], category_i
|
|||||||
agent_json_names, agent_entry_names = count_publishable_agents()
|
agent_json_names, agent_entry_names = count_publishable_agents()
|
||||||
agent_files = [REPO_ROOT / "agents" / name / "agent.json" for name in agent_json_names]
|
agent_files = [REPO_ROOT / "agents" / name / "agent.json" for name in agent_json_names]
|
||||||
agent_entry_files = [REPO_ROOT / "agents" / name / "entry.json" for name in agent_entry_names]
|
agent_entry_files = [REPO_ROOT / "agents" / name / "entry.json" for name in agent_entry_names]
|
||||||
|
team_names = count_publishable_teams()
|
||||||
|
team_entry_files = [REPO_ROOT / "teams" / name / "entry.json" for name in team_names]
|
||||||
skill_md_names, entry_names = count_publishable_skills()
|
skill_md_names, entry_names = count_publishable_skills()
|
||||||
skill_entry_files = sorted((REPO_ROOT / "skills").glob("*/entry.json"))
|
skill_entry_files = sorted((REPO_ROOT / "skills").glob("*/entry.json"))
|
||||||
|
|
||||||
@@ -549,10 +592,18 @@ def validate_market_catalog(report: Report, manifest: dict[str, Any], category_i
|
|||||||
"manifest.json", "market-stats",
|
"manifest.json", "market-stats",
|
||||||
f"stats.totalSkills is {stats.get('totalSkills')}, expected {expected_skills}"
|
f"stats.totalSkills is {stats.get('totalSkills')}, expected {expected_skills}"
|
||||||
))
|
))
|
||||||
|
# The client keeps totalTeams optional so a catalog without teams stays
|
||||||
|
# valid; once a team is listed, or the key is declared at all, it must be exact.
|
||||||
|
declared_teams = stats.get("totalTeams")
|
||||||
|
if (team_names or declared_teams is not None) and declared_teams != len(team_names):
|
||||||
|
report.add(Issue(
|
||||||
|
"manifest.json", "market-stats",
|
||||||
|
f"stats.totalTeams is {declared_teams}, expected {len(team_names)}"
|
||||||
|
))
|
||||||
|
|
||||||
features = manifest.get("features") or []
|
features = manifest.get("features") or []
|
||||||
if isinstance(features, list) and "verified-only" in features:
|
if isinstance(features, list) and "verified-only" in features:
|
||||||
for entry_file in [*agent_entry_files, *skill_entry_files]:
|
for entry_file in [*agent_entry_files, *team_entry_files, *skill_entry_files]:
|
||||||
entry = load_json(entry_file, report, "entry-json")
|
entry = load_json(entry_file, report, "entry-json")
|
||||||
maintainer = entry.get("maintainer") if isinstance(entry, dict) else None
|
maintainer = entry.get("maintainer") if isinstance(entry, dict) else None
|
||||||
verified = maintainer.get("verified") if isinstance(maintainer, dict) else None
|
verified = maintainer.get("verified") if isinstance(maintainer, dict) else None
|
||||||
@@ -566,8 +617,16 @@ def validate_market_catalog(report: Report, manifest: dict[str, Any], category_i
|
|||||||
validate_builtin_skills(report, skill_md_names)
|
validate_builtin_skills(report, skill_md_names)
|
||||||
for agent_file in agent_files:
|
for agent_file in agent_files:
|
||||||
validate_agent_json(report, agent_file, category_ids)
|
validate_agent_json(report, agent_file, category_ids)
|
||||||
for entry_file in [*agent_entry_files, *skill_entry_files]:
|
any_source_kind = frozenset({"git", "web", "zip"})
|
||||||
validate_entry_json(report, entry_file, category_ids, online)
|
for kind, entry_files, source_kinds in (
|
||||||
|
("agent", agent_entry_files, any_source_kind),
|
||||||
|
# A team is installed by forking its repository and updated with git pull;
|
||||||
|
# zip / web cannot express either action, so only git is accepted here.
|
||||||
|
("team", team_entry_files, frozenset({"git"})),
|
||||||
|
("skill", skill_entry_files, any_source_kind),
|
||||||
|
):
|
||||||
|
for entry_file in entry_files:
|
||||||
|
validate_entry_json(report, entry_file, category_ids, online, kind, source_kinds)
|
||||||
|
|
||||||
|
|
||||||
def iter_skill_dirs(targets: Iterable[Path]) -> Iterable[Path]:
|
def iter_skill_dirs(targets: Iterable[Path]) -> Iterable[Path]:
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ metadata:
|
|||||||
description: >-
|
description: >-
|
||||||
创建和管理 Agent 团队,组织多 Agent 协作。Use when 需要多个 Agent 持续协作、建立组织架构,或发布、安装和同步团队仓库时。
|
创建和管理 Agent 团队,组织多 Agent 协作。Use when 需要多个 Agent 持续协作、建立组织架构,或发布、安装和同步团队仓库时。
|
||||||
body: ./SKILL.zh-CN.md
|
body: ./SKILL.zh-CN.md
|
||||||
source_hash: sha256:d781d4d2d18667aa
|
source_hash: sha256:f6d361c54642cb4d
|
||||||
translated_by: human
|
translated_by: human
|
||||||
en-US:
|
en-US:
|
||||||
name: Team Management
|
name: Team Management
|
||||||
@@ -33,7 +33,7 @@ metadata:
|
|||||||
description: >-
|
description: >-
|
||||||
Create and govern Agent teams. Use when multiple Agents need sustained collaboration, an organizational hierarchy, or a team repository must be published, installed, or synchronized.
|
Create and govern Agent teams. Use when multiple Agents need sustained collaboration, an organizational hierarchy, or a team repository must be published, installed, or synchronized.
|
||||||
body: ./SKILL.md
|
body: ./SKILL.md
|
||||||
source_hash: sha256:d781d4d2d18667aa
|
source_hash: sha256:f6d361c54642cb4d
|
||||||
translated_by: human
|
translated_by: human
|
||||||
market:
|
market:
|
||||||
icon: >-
|
icon: >-
|
||||||
@@ -100,6 +100,7 @@ A team defines organization, shared directories, and governance. Actual work is
|
|||||||
| `remove_member` | Remove one member | `teamId`, `agentId` |
|
| `remove_member` | Remove one member | `teamId`, `agentId` |
|
||||||
| `remove_members` | Remove members in a batch | `teamId`, `members` |
|
| `remove_members` | Remove members in a batch | `teamId`, `members` |
|
||||||
| `set_supervisor` | Replace the supervisor | `teamId`, `agentId` |
|
| `set_supervisor` | Replace the supervisor | `teamId`, `agentId` |
|
||||||
|
| `set_member_source` | Declare where a member Agent comes from | `teamId`, `agentId`, `memberSource`; `git` needs `url` (https, will be cloned) and `ref` (defaults to `main`), `registry` needs `id`+`version`, `core`/`local` need nothing else |
|
||||||
| `update` | Partially update team configuration | `teamId`; supports `name/type/isolation/parentTeamId/description/avatar/avatarImage` |
|
| `update` | Partially update team configuration | `teamId`; supports `name/type/isolation/parentTeamId/description/avatar/avatarImage` |
|
||||||
| `promote` | Promote an ephemeral team to persistent | `teamId`; one-way and never implicit |
|
| `promote` | Promote an ephemeral team to persistent | `teamId`; one-way and never implicit |
|
||||||
| `disband` | Disband a team | `teamId`; explain impact and confirm unless explicitly requested |
|
| `disband` | Disband a team | `teamId`; explain impact and confirm unless explicitly requested |
|
||||||
@@ -157,6 +158,7 @@ Example:
|
|||||||
|
|
||||||
- Prefer batch member actions to avoid observable intermediate states.
|
- Prefer batch member actions to avoid observable intermediate states.
|
||||||
- `set_supervisor` uses `agentId`; first verify that the Agent does not already supervise another team.
|
- `set_supervisor` uses `agentId`; first verify that the Agent does not already supervise another team.
|
||||||
|
- `set_member_source` declares provenance, it does not move files. A team whose roster still contains a `local` member is **not distributable** — `members.lock.json` cannot pin an ID that exists only on this machine, so a fork elsewhere would silently come up short a member. Switch each member to `git` or `registry` before publishing, then `resolve` to write the lock. It cannot change a member's `role`; use `set_supervisor` for that.
|
||||||
- `update` is a patch: omitted fields remain unchanged.
|
- `update` is a patch: omitted fields remain unchanged.
|
||||||
- `parentTeamId: null` detaches the team and makes it top-level; an empty string is invalid.
|
- `parentTeamId: null` detaches the team and makes it top-level; an empty string is invalid.
|
||||||
- `type` only allows `ephemeral → persistent`. Repeating the current value is idempotent; use `promote` for an explicit upgrade.
|
- `type` only allows `ephemeral → persistent`. Repeating the current value is idempotent; use `promote` for an explicit upgrade.
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
| `remove_member` | 移除一个成员 | `teamId`、`agentId` |
|
| `remove_member` | 移除一个成员 | `teamId`、`agentId` |
|
||||||
| `remove_members` | 批量移除成员 | `teamId`、`members` |
|
| `remove_members` | 批量移除成员 | `teamId`、`members` |
|
||||||
| `set_supervisor` | 更换组长 | `teamId`、`agentId` |
|
| `set_supervisor` | 更换组长 | `teamId`、`agentId` |
|
||||||
|
| `set_member_source` | 声明成员 Agent 的来源 | `teamId`、`agentId`、`memberSource`;`git` 必填 `url`(https,将被 clone)与 `ref`(默认 `main`),`registry` 必填 `id`+`version`,`core`/`local` 无附加字段 |
|
||||||
| `update` | 部分更新团队配置 | `teamId`;可更新 `name/type/isolation/parentTeamId/description/avatar/avatarImage` |
|
| `update` | 部分更新团队配置 | `teamId`;可更新 `name/type/isolation/parentTeamId/description/avatar/avatarImage` |
|
||||||
| `promote` | 临时团队升级为持久团队 | `teamId`;单向操作,不得隐式执行 |
|
| `promote` | 临时团队升级为持久团队 | `teamId`;单向操作,不得隐式执行 |
|
||||||
| `disband` | 解散团队 | `teamId`;若用户未明确要求,先说明影响并确认 |
|
| `disband` | 解散团队 | `teamId`;若用户未明确要求,先说明影响并确认 |
|
||||||
@@ -100,6 +101,7 @@
|
|||||||
|
|
||||||
- 成员增删优先使用批量 action,避免多次调用产生中间状态。
|
- 成员增删优先使用批量 action,避免多次调用产生中间状态。
|
||||||
- `set_supervisor` 使用 `agentId` 指定新组长;先确认其未担任其他团队组长。
|
- `set_supervisor` 使用 `agentId` 指定新组长;先确认其未担任其他团队组长。
|
||||||
|
- `set_member_source` 声明来源,不搬运文件。名册里只要还留着 `local` 成员,团队就**不可分发**——`members.lock.json` 无法锁定一个只存在于本机的 ID,别处 fork 出来会静默缺员。发布前把每个成员改为 `git` 或 `registry`,再 `resolve` 写锁。它改不了成员的 `role`,换组长用 `set_supervisor`。
|
||||||
- `update` 是部分更新:未传字段保持原值。
|
- `update` 是部分更新:未传字段保持原值。
|
||||||
- `parentTeamId: null` 表示摘除父团队成为顶层团队;空字符串非法。
|
- `parentTeamId: null` 表示摘除父团队成为顶层团队;空字符串非法。
|
||||||
- `type` 只允许 `ephemeral → persistent`。原值幂等更新可以接受;明确升级优先使用 `promote`。
|
- `type` 只允许 `ephemeral → persistent`。原值幂等更新可以接受;明确升级优先使用 `promote`。
|
||||||
|
|||||||
101
teams/contract-review-team/catalog-metadata.v1.json
Normal file
101
teams/contract-review-team/catalog-metadata.v1.json
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../../schemas/catalog-metadata.v1.schema.json",
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"identity": {
|
||||||
|
"kind": "team",
|
||||||
|
"id": "contract-review-team"
|
||||||
|
},
|
||||||
|
"presentation": {
|
||||||
|
"defaultLocale": "en-US",
|
||||||
|
"i18n": {
|
||||||
|
"en-US": {
|
||||||
|
"name": "Contract Review Team",
|
||||||
|
"summary": "A six-member contract review team: intake gating, clause structuring, risk scanning and jurisdiction checks, closed by an independent reviewer who re-verifies against the source text before reporting",
|
||||||
|
"description": "Six members work a fixed seven-step chain. The intake officer runs hard checks and freezes the reviewed baseline, stopping the pipeline outright when a blocking condition is hit. The clause officer rewrites the body and attachments into structured facts anchored to clause numbers and page references, keeping genuine ambiguity intact instead of resolving it early. Risk scanning and jurisdiction checks then run in parallel over that same structured record. The reviewer closes the chain: it does not read the earlier members' reasoning, goes back to the source text to re-establish evidence, and only then composes the report.\n\nThis is a team, not one agent running the whole flow end to end. Responsibilities are split across six members and one independent review pass is kept, so a run takes longer but leaves a traceable evidence chain and a clear boundary for each finding. Pick the shape that fits the job.\n\nThe output is supporting material for review work. It is not legal advice, it carries no legal effect, and it does not replace a qualified lawyer or in-house counsel. Every conclusion has to be checked by a qualified person before it is relied on."
|
||||||
|
},
|
||||||
|
"zh-CN": {
|
||||||
|
"name": "合同审查团队",
|
||||||
|
"summary": "六名成员分工的合同审查团队:输入治理、条款结构化、风险识别、法域合规,最后由独立复核成员回原文重新取证再出报告",
|
||||||
|
"description": "六名成员按固定七步链路协作。输入治理官先做硬校验并冻结受审基线,命中阻断项直接终止流水线;条款结构化官把正文与附件转写为带条款编号和页码锚点的结构化事实,原始歧义原样保留,不替下游提前消歧;风险识别官与法域合规官在同一份结构化事实上并行工作;复核出报告官收尾——不读前序成员的推理过程,回到原文重新取证后独立判定,再汇总成报告。\n\n这是一个团队,不是单个智能体跑完全流程:职责拆到六个成员,并保留一次独立复核,因此一次运行耗时更长,换来的是可追溯的证据链和清晰的责任边界。按任务需要选择合适的形态即可。\n\n产出是供审阅使用的辅助材料,不构成法律意见,不具有法律效力,也不替代律师或法务的专业判断;任何结论都需由具备资质的人员复核后再使用。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"kind": "letter",
|
||||||
|
"value": "合审"
|
||||||
|
},
|
||||||
|
"category": "business",
|
||||||
|
"tags": [
|
||||||
|
"contract-review",
|
||||||
|
"multi-agent-team",
|
||||||
|
"risk",
|
||||||
|
"compliance",
|
||||||
|
"legal-collaboration"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"release": {
|
||||||
|
"state": "known",
|
||||||
|
"version": "0.1.1",
|
||||||
|
"versionScheme": "semver"
|
||||||
|
},
|
||||||
|
"timestamps": {
|
||||||
|
"catalogUpdatedAt": {
|
||||||
|
"state": "known",
|
||||||
|
"value": "2026-09-01",
|
||||||
|
"precision": "day"
|
||||||
|
},
|
||||||
|
"releasePublishedAt": {
|
||||||
|
"state": "known",
|
||||||
|
"value": "2026-09-01T05:18:09Z",
|
||||||
|
"precision": "second"
|
||||||
|
},
|
||||||
|
"reviewedAt": {
|
||||||
|
"state": "unknown"
|
||||||
|
},
|
||||||
|
"upstreamObservedAt": {
|
||||||
|
"state": "known",
|
||||||
|
"value": "2026-09-01T05:21:24Z",
|
||||||
|
"precision": "second"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"provenance": {
|
||||||
|
"content": {
|
||||||
|
"kind": "git",
|
||||||
|
"url": "https://github.com/desirecore-agent/contract-review-team.git",
|
||||||
|
"ref": "73cd87a9901cc548871927e9d5dbec8e4cc6c2b1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"governance": {
|
||||||
|
"stewardship": "official",
|
||||||
|
"availability": "listing-only",
|
||||||
|
"license": {
|
||||||
|
"state": "known",
|
||||||
|
"value": "MIT",
|
||||||
|
"evidencePath": "LICENSE"
|
||||||
|
},
|
||||||
|
"redistribution": "source-pointer-only",
|
||||||
|
"upstreamMaintainer": {
|
||||||
|
"name": "DesireCore Agent",
|
||||||
|
"url": "https://github.com/desirecore-agent",
|
||||||
|
"verified": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compatibility": {
|
||||||
|
"platforms": {
|
||||||
|
"state": "unknown"
|
||||||
|
},
|
||||||
|
"requiredClientVersion": "10.0.137"
|
||||||
|
},
|
||||||
|
"spec": {
|
||||||
|
"kind": "team",
|
||||||
|
"supervisorName": "合同审查统筹官",
|
||||||
|
"supervisorAgentId": "contract-review-lead",
|
||||||
|
"memberCount": 6,
|
||||||
|
"memberNames": [
|
||||||
|
"合同输入治理官",
|
||||||
|
"条款结构化官",
|
||||||
|
"合同风险识别官",
|
||||||
|
"法域合规官",
|
||||||
|
"合同复核出报告官"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
53
teams/contract-review-team/entry.json
Normal file
53
teams/contract-review-team/entry.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"id": "contract-review-team",
|
||||||
|
"name": "Contract Review Team",
|
||||||
|
"category": "business",
|
||||||
|
"tags": [
|
||||||
|
"contract-review",
|
||||||
|
"multi-agent-team",
|
||||||
|
"risk",
|
||||||
|
"compliance",
|
||||||
|
"legal-collaboration"
|
||||||
|
],
|
||||||
|
"latestVersion": "0.1.1",
|
||||||
|
"i18n": {
|
||||||
|
"zh-CN": {
|
||||||
|
"name": "合同审查团队",
|
||||||
|
"shortDesc": "六名成员分工的合同审查团队:输入治理、条款结构化、风险识别、法域合规,最后由独立复核成员回原文重新取证再出报告"
|
||||||
|
},
|
||||||
|
"en-US": {
|
||||||
|
"name": "Contract Review Team",
|
||||||
|
"shortDesc": "A six-member contract review team: intake gating, clause structuring, risk scanning and jurisdiction checks, closed by an independent reviewer who re-verifies against the source text before reporting"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"maintainer": {
|
||||||
|
"name": "DesireCore Agent",
|
||||||
|
"verified": true,
|
||||||
|
"account": "desirecore-agent",
|
||||||
|
"url": "https://github.com/desirecore-agent"
|
||||||
|
},
|
||||||
|
"stewardship": "official",
|
||||||
|
"license": "MIT",
|
||||||
|
"redistribution": "source-pointer-only",
|
||||||
|
"source": {
|
||||||
|
"kind": "git",
|
||||||
|
"repoUrl": "https://github.com/desirecore-agent/contract-review-team.git",
|
||||||
|
"repoBranch": "main",
|
||||||
|
"ref": "73cd87a9901cc548871927e9d5dbec8e4cc6c2b1"
|
||||||
|
},
|
||||||
|
"requiredClientVersion": "10.0.137",
|
||||||
|
"avatar": {
|
||||||
|
"t": "合审",
|
||||||
|
"bg": "linear-gradient(135deg, #5856D6, #3634A3)"
|
||||||
|
},
|
||||||
|
"supervisorName": "合同审查统筹官",
|
||||||
|
"supervisorAgentId": "contract-review-lead",
|
||||||
|
"memberCount": 6,
|
||||||
|
"memberNames": [
|
||||||
|
"合同输入治理官",
|
||||||
|
"条款结构化官",
|
||||||
|
"合同风险识别官",
|
||||||
|
"法域合规官",
|
||||||
|
"合同复核出报告官"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user