#!/usr/bin/env python3 """生成与机械校验 Skill 发现总索引 `.agent/skills/_index.md`。 发现合同(见 AGENTS.md §3):任何 agent(不限宿主)读 AGENTS.md 后必须读总索引, 按 `skill_file` 读取所需能力的 `SKILL.md`;不依赖任何 coding agent 的 skill 自动发现, 物理目录位置只是索引里的数据。 索引只登记三字段: - `skill_name`:目录名,即调用名; - `skill_file`:合同文件路径(来自 `harness/manifests/skills.json` 的 `skill_path`); - `skill_description`:适用与边界描述,与 SKILL.md frontmatter 的 `description` 逐字一致 (frontmatter 是 SoT,索引是生成物)。 生命周期分组取自 `harness/manifests/skills.json` 的 `lifecycle` 字段;取值定义见 `harness/specs/skill-quality-rubric.md`。 用法: .venv/bin/python harness/skills_index.py --check # 校验索引与磁盘、manifest 一致(默认) .venv/bin/python harness/skills_index.py --write # 按磁盘与 manifest 重新生成索引 """ from __future__ import annotations import argparse import difflib from pathlib import Path from typing import Optional, Sequence import yaml # 生命周期域 → 索引分节标题;顺序即索引分节顺序,标题沿用既有中文域名。 LIFECYCLE_SECTIONS: tuple[tuple[str, str], ...] = ( ("platform", "0 平台底座"), ("ingest", "1 素材导入与拆解"), ("knowledge", "2 知识与上下文供给"), ("concept", "3 概念与前期设计"), ("planning", "4 结构与规划"), ("writing", "5 正文写作与呈现"), ("review", "6 检测、评分与诊断"), ("humanization", "7 去 AI 味与人感"), ("sovereignty", "8 候选主权与落库"), ) INDEX_RELPATH = Path(".agent") / "skills" / "_index.md" MANIFEST_RELPATH = Path("harness") / "manifests" / "skills.json" HEADER = """# Skill 发现总索引 > **发现合同**:任何 agent(不限宿主)读 [`AGENTS.md`](../../AGENTS.md) 后必须读本索引,\ 需要某项能力时按 `skill_file` 读对应 `SKILL.md`。发现只靠 AGENTS.md → 本索引 → SKILL.md \ 的渐进披露,不依赖任何 coding agent 的 skill 自动发现;物理目录位置只是索引里的数据。 本索引只登记三字段:`skill_name`(目录名,即调用名)、`skill_file`(合同文件路径)、\ `skill_description`(适用与边界描述,与 SKILL.md frontmatter 逐字一致,frontmatter 是 SoT)。\ 分类字段(`lifecycle` / `invocation` / `side_effects` / `compounding`)逐个登记在 \ [`harness/manifests/skills.json`](../../harness/manifests/skills.json),由 \ `harness/skill_harness.py` 机械校验,不在本索引重复。 本文件由 `harness/skills_index.py --write` 生成,手改会被覆盖;一致性由 `--check` 与 \ `tests/architecture/test_skills_index.py` 机械把关。按生命周期分域,共 {count} 个 skill。 """ def _load_manifest(root: Path) -> dict[str, dict[str, str]]: """读取 skills.json,返回 name → 条目;仅接受与磁盘一致的规范路径。""" import json manifest_path = root / MANIFEST_RELPATH data = json.loads(manifest_path.read_text(encoding="utf-8")) entries = data["skills"] if isinstance(data, dict) and "skills" in data else data result: dict[str, dict[str, str]] = {} for entry in entries: result[entry["name"]] = entry return result def _read_description(skill_md: Path) -> str: """取 SKILL.md frontmatter 的 description 并折叠换行为单行。""" text = skill_md.read_text(encoding="utf-8") if not text.startswith("---\n"): raise ValueError(f"{skill_md}: 缺少 frontmatter") end = text.index("\n---\n", 4) fields = yaml.safe_load(text[4:end]) description = (fields or {}).get("description") if not isinstance(description, str) or not description.strip(): raise ValueError(f"{skill_md}: frontmatter 缺少非空 description") # YAML 多行块按行折叠;中文行间接缝不需要空格。 return "".join(line.strip() for line in description.strip().splitlines()) def build_index_text(root: Path) -> tuple[str, int]: """按磁盘 + skills.json 组装索引全文,返回 (文本, skill 数)。""" skills_root = root / ".agent" / "skills" manifest = _load_manifest(root) # 磁盘事实:目录名 → description;与 manifest 双向对账。 disk: dict[str, str] = {} for skill_dir in sorted(skills_root.iterdir(), key=lambda item: item.name): skill_md = skill_dir / "SKILL.md" if not skill_md.exists(): continue disk[skill_dir.name] = _read_description(skill_md) only_disk = sorted(set(disk) - set(manifest)) only_manifest = sorted(set(manifest) - set(disk)) if only_disk or only_manifest: parts = [] if only_disk: parts.append(f"磁盘有而 skills.json 缺: {', '.join(only_disk)}") if only_manifest: parts.append(f"skills.json 有而磁盘缺: {', '.join(only_manifest)}") raise ValueError("磁盘与 skills.json 不一致:" + ";".join(parts)) for name, entry in manifest.items(): expected = f".agent/skills/{name}/SKILL.md" if entry.get("skill_path") != expected: raise ValueError( f"{name}: skills.json skill_path={entry.get('skill_path')!r}," f"期望 {expected!r}" ) lifecycle = entry.get("lifecycle") if lifecycle not in dict(LIFECYCLE_SECTIONS): raise ValueError(f"{name}: 未知 lifecycle={lifecycle!r}") lines: list[str] = [HEADER.format(count=len(disk)).rstrip()] for lifecycle, title in LIFECYCLE_SECTIONS: names = sorted( name for name, entry in manifest.items() if entry["lifecycle"] == lifecycle ) if not names: continue lines.append("") lines.append(f"## {title}") lines.append("") lines.append("| skill_name | skill_file | skill_description |") lines.append("|---|---|---|") for name in names: lines.append( f"| {name} | `.agent/skills/{name}/SKILL.md` | {disk[name]} |" ) lines.append("") return "\n".join(lines), len(disk) def check(root: Path) -> Optional[str]: """校验现有索引与重建结果一致;一致返回 None,否则返回差异文本。""" expected, _ = build_index_text(root) index_path = root / INDEX_RELPATH actual = ( index_path.read_text(encoding="utf-8") if index_path.exists() else "" ) if actual == expected: return None diff = difflib.unified_diff( actual.splitlines(keepends=True), expected.splitlines(keepends=True), fromfile=str(INDEX_RELPATH) + "(现状)", tofile=str(INDEX_RELPATH) + "(按磁盘与 skills.json 重建)", n=2, ) return "".join(diff) def main(argv: Optional[Sequence[str]] = None) -> int: parser = argparse.ArgumentParser( description="生成与机械校验 .agent/skills/_index.md(三字段发现总索引)。" ) parser.add_argument("--root", default=".", help="项目根目录,默认为当前目录") group = parser.add_mutually_exclusive_group() group.add_argument( "--check", action="store_true", help="校验索引与磁盘、skills.json 一致(默认模式)", ) group.add_argument( "--write", action="store_true", help="按磁盘与 skills.json 重新生成索引" ) args = parser.parse_args(argv) root = Path(args.root).resolve() try: if args.write: text, count = build_index_text(root) (root / INDEX_RELPATH).write_text(text, encoding="utf-8") print(f"已重新生成 {INDEX_RELPATH}({count} 个 skill)") return 0 diff = check(root) except (ValueError, KeyError, FileNotFoundError) as error: print(f"skills_index 失败:{error}") return 2 if diff is None: print("skills_index 一致:索引与磁盘、skills.json 相符") return 0 print("skills_index 漂移:请用 harness/skills_index.py --write 重新生成") print(diff) return 1 if __name__ == "__main__": raise SystemExit(main())