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:
2026-09-01 04:15:45 -04:00
committed by GitHub
parent df5959eebf
commit e77c658958
15 changed files with 1277 additions and 64 deletions

View File

@@ -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]:
return {
"$schema": "../../schemas/catalog-metadata.v1.schema.json",
@@ -99,8 +176,10 @@ class CatalogMetadataValidatorTests(unittest.TestCase):
self.root = Path(self.tempdir.name)
(self.root / "schemas").mkdir()
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 / "teams").mkdir()
(self.root / "skills" / "example-skill").mkdir(parents=True)
self.write_json(
self.root / "manifest.json",
@@ -218,6 +297,254 @@ class CatalogMetadataValidatorTests(unittest.TestCase):
})
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:
entry_path, _ = self.write_agent_pointer_case()
report = self.validate(require_complete=True)

View File

@@ -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
file; it never selects an arbitrary metadata path and never declares the
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
@@ -28,6 +32,19 @@ from jsonschema import Draft7Validator, FormatChecker, ValidationError, validato
REPO_ROOT = Path(__file__).resolve().parents[2]
SCHEMA_PATH = REPO_ROOT / "schemas" / "catalog-metadata.v1.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"
EXPECTED_SCHEMA_REF = "../../schemas/catalog-metadata.v1.schema.json"
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}")
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)
@@ -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
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
root_kind = parent.parent.name
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")
if data is None:
return None
if is_pointer:
errors = list(agent_entry_validator.iter_errors(data))
for error in errors:
report.add(_rel(legacy_path, root), "agent-entry-schema", f"{_json_path(error)}: {error.message}")
if errors:
return None
if is_pointer and _report_entry_schema_errors(
report, legacy_path, root, entry_validators["agent"], "agent-entry-schema", data
):
return None
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")
# 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,
)
if root_kind == "teams":
return _load_team_pointer(sidecar, parent, report, root, entry_validators["team"])
if root_kind != "skills":
report.add(
_rel(sidecar, root),
"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
@@ -361,7 +451,7 @@ def _validate_content_consistency(
}
for sidecar_key, legacy_key in mapping.items():
legacy_value = source.get(legacy_key)
if legacy.kind == "agent":
if legacy.kind in {"agent", "team"}:
declared_value = content.get(sidecar_key)
# A sidecar must describe the exact pointer that the client installs,
# 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
legacy_value = None if legacy_value == "" else 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:
_compare(
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(
report: Report,
rel: str,
@@ -578,7 +740,7 @@ def validate_sidecar(
report: Report,
root: Path,
supported_locales: set[str],
agent_entry_validator,
entry_validators: dict[str, Any],
) -> None:
rel = _rel(sidecar_path, root)
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):
return
legacy = load_legacy(sidecar_path, report, root, agent_entry_validator)
legacy = load_legacy(sidecar_path, report, root, entry_validators)
if legacy is None:
return
identity = sidecar["identity"]
@@ -640,8 +802,19 @@ def validate_sidecar(
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_governance_consistency(report, rel, sidecar, legacy)
_validate_license_evidence(report, rel, sidecar, legacy, sidecar_path.parent)
_validate_collection(report, rel, sidecar, legacy, supported_locales)
governance = sidecar.get("governance")
@@ -690,11 +863,12 @@ def validate_sidecar(
)
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")):
label = "Agent" if legacy.kind == "agent" else "Team"
report.add(
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):
@@ -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(
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"))
pointer_count = sum(1 for _ in (root / "skills").glob("*/entry.json"))
collection_count = 0
@@ -750,6 +926,7 @@ def _validate_stats(root: Path, report: Report, sidecars: Iterable[Path]) -> Non
sidecar_list = list(sidecars)
report.stats = {
"agents": agent_count,
"teams": team_count,
"builtinSkills": builtin_count,
"pointerSkills": 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}")
if stats.get("totalSkills") != 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:
@@ -781,32 +963,42 @@ def validate_repository(root: Path = REPO_ROOT, *, require_complete: bool = Fals
except Exception as exc: # jsonschema raises several SchemaError subclasses
report.add(_rel(schema_path, root), "catalog-schema", f"invalid JSON Schema: {exc}")
return report
agent_entry_schema = _read_json(root / "schemas" / AGENT_ENTRY_SCHEMA_NAME, report, root, "agent-entry-schema")
if agent_entry_schema is None:
return report
try:
AgentEntryValidator.check_schema(agent_entry_schema)
except Exception as exc:
report.add(f"schemas/{AGENT_ENTRY_SCHEMA_NAME}", "agent-entry-schema", f"invalid JSON Schema: {exc}")
return report
agent_entry_validator = AgentEntryValidator(agent_entry_schema, format_checker=FormatChecker())
entry_validators: dict[str, Any] = {}
for kind, schema_name, rule in (
("agent", AGENT_ENTRY_SCHEMA_NAME, "agent-entry-schema"),
("team", TEAM_ENTRY_SCHEMA_NAME, "team-entry-schema"),
):
entry_schema = _read_json(root / "schemas" / schema_name, report, root, rule)
if entry_schema is None:
return report
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())
sidecars = _discover_sidecars(root)
supported_locales = _load_supported_locales(root, report)
catalog_roots = {root / name for name in CATALOG_ROOTS}
for sidecar in sidecars:
if sidecar.parent.parent not in {root / "agents", root / "skills"}:
if sidecar.parent.parent not in catalog_roots:
report.add(
_rel(sidecar, root),
"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
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)
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)
if actual != expected:
report.add(
@@ -823,7 +1015,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument(
"--require-complete",
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)
report = validate_repository(require_complete=args.require_complete)