将角色与 Skill 从 .claude 迁入 .agent,移除 Claude CLI 运行时并接入固定 Opus 角色 profile、完整 schema、预算 deadline、raw 与回执证据链。 同步拆分 Skill 职责、复利 lesson、Gate 回放、Dashboard 人审入口、数据库登记和机械门禁;候选设计正文不包含在本提交中。
201 lines
8.2 KiB
Python
201 lines
8.2 KiB
Python
#!/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())
|