muse-agent-example/harness/skills_index.py
zizi 091b66a9bb 重构: 收敛 Agent/Skill 运行时与创作质量闭环
将角色与 Skill 从 .claude 迁入 .agent,移除 Claude CLI 运行时并接入固定 Opus 角色 profile、完整 schema、预算 deadline、raw 与回执证据链。

同步拆分 Skill 职责、复利 lesson、Gate 回放、Dashboard 人审入口、数据库登记和机械门禁;候选设计正文不包含在本提交中。
2026-08-22 02:12:32 +08:00

201 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())