mirror of
https://git.openapi.site/https://github.com/desirecore/market.git
synced 2026-09-05 18:23:50 +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:
@@ -15,9 +15,11 @@ Checks:
|
||||
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.
|
||||
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.
|
||||
10. Skill, Agent, and entry.json category references exist in categories.json.
|
||||
9. Skill/Agent/Team counts and builtin skill index match the repository contents.
|
||||
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.
|
||||
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.
|
||||
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)
|
||||
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)$", re.DOTALL)
|
||||
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
|
||||
@@ -362,6 +370,15 @@ def count_publishable_agents() -> tuple[list[str], list[str]]:
|
||||
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:
|
||||
builtin_path = REPO_ROOT / "builtin-skills.json"
|
||||
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"))
|
||||
|
||||
|
||||
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()
|
||||
entry = load_json(entry_file, report, "entry-json")
|
||||
if not entry:
|
||||
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:
|
||||
if key not in entry:
|
||||
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"))
|
||||
|
||||
icon = entry.get("icon")
|
||||
if not isinstance(icon, str) or not icon.strip():
|
||||
report.add(Issue(rel, "entry-json", "icon must be a non-empty inline SVG string"))
|
||||
else:
|
||||
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}"))
|
||||
icon_rendered = kind in ICON_RENDERED_KINDS
|
||||
# An omitted icon is only a problem where the client actually renders one.
|
||||
if icon is not None or icon_rendered:
|
||||
if not isinstance(icon, str) or not icon.strip():
|
||||
report.add(Issue(rel, "entry-json", "icon must be a non-empty inline SVG string"))
|
||||
else:
|
||||
if not icon_rendered:
|
||||
report.add(Issue(
|
||||
rel, "entry-json",
|
||||
f"icon is dead weight for a {kind} listing: the client renders {kind} cards from "
|
||||
"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")
|
||||
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):
|
||||
report.add(Issue(rel, "entry-json", "source must be an object"))
|
||||
return
|
||||
kind = source.get("kind")
|
||||
if kind not in {"git", "web", "zip"}:
|
||||
report.add(Issue(rel, "entry-json", f"source.kind '{kind}' must be one of git/web/zip"))
|
||||
# 刻意不叫 `kind`:那是本函数的参数,表示条目种类(agent/skill/team)。
|
||||
# 这里是 source 的传输种类(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")
|
||||
if not isinstance(repo_url, str) or not repo_url.strip():
|
||||
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_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]
|
||||
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_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",
|
||||
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 []
|
||||
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")
|
||||
maintainer = entry.get("maintainer") if isinstance(entry, 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)
|
||||
for agent_file in agent_files:
|
||||
validate_agent_json(report, agent_file, category_ids)
|
||||
for entry_file in [*agent_entry_files, *skill_entry_files]:
|
||||
validate_entry_json(report, entry_file, category_ids, online)
|
||||
any_source_kind = frozenset({"git", "web", "zip"})
|
||||
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]:
|
||||
|
||||
Reference in New Issue
Block a user