muse-agent-example/harness/skill_harness.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

1568 lines
56 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/*/SKILL.md`,不连接数据库、网络或模型,也不修改
工作树。机器调用使用 :func:`audit_skills`,命令行入口同时支持人读摘要和 JSON。
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import Counter
from pathlib import Path
from typing import Any, Iterable, Optional, Sequence
SCHEMA_VERSION = 4
SKILLS_ROOT = Path(".agent") / "skills"
DEFAULT_MANIFEST_PATH = Path("harness") / "manifests" / "skills.json"
# 分类取值与质量预算的定义 Owner 是 harness/specs/skill-quality-rubric.md;本模块只做机械判定。
VALID_LIFECYCLES = (
"platform",
"ingest",
"knowledge",
"concept",
"planning",
"writing",
"review",
"humanization",
"sovereignty",
)
VALID_INVOCATIONS = ("orchestrated", "model_routed")
VALID_SIDE_EFFECTS = ("none", "db_write", "external_call")
VALID_COMPOUNDING = ("closed_loop", "partial", "none")
LINE_BUDGET = 120
DESCRIPTION_BUDGET = 200
# 创作方法密集的周期段需要列同义触发说法,描述预算放宽。
WIDE_DESCRIPTION_LIFECYCLES = ("concept", "planning", "writing", "review")
WIDE_DESCRIPTION_BUDGET = 1000
# 必备节的机械判定只覆盖标题词汇稳定的一部分;唯一目的、消费者仍由人工审查。
_SECTION_PATTERNS = {
"输入": re.compile(r"输入|用法|入口|参数"),
"输出": re.compile(r"输出|产出|返回|报告"),
"红线": re.compile(r"红线|禁止|不得|边界|纪律|铁律"),
"数据边界": re.compile(r"数据边界|数据库读写|落库|副作用|存储|数据与"),
"复利合同": re.compile(r"复利|升格|回写|沉淀|经验"),
}
_BASE_SECTIONS = ("输入", "输出", "红线")
_SIDE_EFFECT_SECTIONS = ("数据边界",)
_COMPOUNDING_SECTIONS = ("复利合同",)
_FRONTMATTER_KEY = re.compile(r"^(?P<indent>\s*)(?P<key>[A-Za-z0-9_-]+)\s*:\s*(?P<value>.*?)\s*$")
# 单个 references 文件的体量上限:references/ 本就是 D6 的外移目的地,只对
# “一次读进来就吃掉整窗”的超大单文件设限,不对 references/ 总量设预算。
REFERENCE_FILE_BUDGET_BYTES = 20 * 1024
# 跨 Skill 检查用的文档集合与忽略目录。
_DOC_SUFFIXES = (".md",)
_SCAN_SKIP_DIRS = frozenset({".git", ".venv", "__pycache__", "node_modules", ".pytest_cache"})
# 内部代号引用:形如 `MERGE_PLAN §4`。全大写加节号是很窄的签名,
# 正常业务词与 DDL-111 这类带连字符的编号都不会命中。
_CODENAME_CITATION = re.compile(r"(?<![A-Za-z0-9_])([A-Z][A-Z0-9_]{3,})(?:\.md)?\s*§")
# 确有外部规范以该形态引用时登记在此,不靠放宽正则。
_CODENAME_ALLOWLIST = frozenset({"RFC", "ISO", "IEEE"})
_MARKDOWN_LINK = re.compile(r"\[[^\]\n]*\]\(\s*([^)\s]+)")
_INLINE_DOC_PATH = re.compile(
r"`((?:\./)?(?:references|scripts)/[A-Za-z0-9_.\u4e00-\u9fff-]+(?:/[A-Za-z0-9_.\u4e00-\u9fff-]+)*"
r"\.[A-Za-z0-9]+)`"
)
_EXEMPLAR_HEADING = re.compile(r"^#{2,6}\s+(?:案例|Case)\s*\d+\s*[::]\s*(\S.*?)\s*$")
# description 里点名的兄弟 Skill 必须存在;基础设施标识符登记在此。
_SKILL_NAME_TOKEN = re.compile(r"(?<![A-Za-z0-9_-])([a-z][a-z0-9]*(?:-[a-z0-9]+){1,4})(?![A-Za-z0-9-])")
_KNOWN_NON_SKILL_TOKENS = frozenset(
{
"muse-example",
"muse-db",
"muse-llm",
"muse-embed",
"muse-deai",
"new-api",
"design-docs",
"agent-example",
"oh-my-muse",
}
)
# 触发词碰撞:只看“何时用”那半段,`不适用于` 之后是共享排除项,两个 Skill
# 都声明自己不做某事不构成冲突。
_DESCRIPTION_EXCLUSION_MARKER = "不适用于"
# 只在引号、斜杠与中英标点上切分,不切普通空格:这样 "show don't tell" 保持
# 为一个触发说法,而中文触发词本来就靠标点分隔。
# 撇号不切:don't 是一个词,不是两个触发说法。
_TRIGGER_SPLIT = re.compile(r"[\n\"“”`/|、,,。;;::()()\[\]【】《》〈〉…—+*]+")
# 结构性小标题与全域通用词不是触发词,命中它们不构成路由冲突。
_TRIGGER_STOPWORDS = frozenset(
{
"激活场景",
"关键 trigger",
"关键trigger",
"何时用",
"何时不用",
"做什么",
"触发词",
"关键词",
}
)
_TRIGGER_MIN_LEN = 3
_TRIGGER_MAX_LEN = 24
# 存量已清零的检查直接阻断:它们的成本只在“别再犯”,留在 advisories 里等
# --strict 等于给回归开窗口。存量未清零的检查仍进 advisories。
_BLOCKING_ADVISORY_CODES = frozenset(
{
"dangling_internal_codename",
"reference_target_missing",
"orphan_reference_file",
"description_names_unknown_skill",
}
)
# 一方把另一方整句包进更长说法时也算重合,但短词包含噪声大,只对够长的判定。
_TRIGGER_CONTAINMENT_MIN_LEN = 5
_SCRIPT_TEST_PATTERN = re.compile(
r"^(?:test_[A-Za-z0-9][A-Za-z0-9_.-]*\.py|[A-Za-z0-9][A-Za-z0-9_.-]*_test\.py)$",
re.IGNORECASE,
)
_COVERAGE_TOOL_PATTERN = re.compile(
r"(?:\bcoverage\.py\b|\bpytest-cov\b|"
r"(?<![A-Za-z0-9_])--cov(?:[-A-Za-z0-9_]+)?(?:\s|=|:|$)|"
r"\bcoverage\s+report\b)",
re.IGNORECASE,
)
_EXPLICIT_TEST_COVERAGE_PATTERN = re.compile(
r"(?:"
r"(?:测试(?:用例|代码)?|单元(?:测试)?|集成(?:测试)?|回归测试|开发测试|开发验证|"
r"代码|分支|语句|行|函数|方法)\s*覆盖率"
r"|(?:unit(?:\s+test)?|integration(?:\s+test)?|regression\s+test|"
r"test|code|branch|statement|line|function|method)\s+coverage"
r")",
re.IGNORECASE,
)
_COVERAGE_PERCENT_PATTERN = re.compile(
r"覆盖率\s*(?:达到|为|至少|不低于|不少于|应达到|>=|>|:|:)?\s*"
r"\d+(?:\.\d+)?\s*[%%]",
re.IGNORECASE,
)
_BUSINESS_COVERAGE_CONTEXT_PATTERN = re.compile(
r"(?:细纲|事件|硬约束|软约束|伏笔|实体|设定|情节|场景|知识|事实|正文|"
r"作品|业务|候选|规划|大纲|章)[^。\n]{0,24}覆盖率"
r"|覆盖率[^。\n]{0,24}(?:细纲|事件|硬约束|软约束|伏笔|实体|设定|情节|"
r"场景|知识|事实|正文|作品|业务|候选|规划|大纲|章)",
re.IGNORECASE,
)
# 这些规则只针对运行时 Skill 文档中的明显开发验证残留;业务正文中的普通“检查”
# 等词不在扫描范围内。规则保持确定性,方便报告中的 code 作为机械门禁输入。
_DEVELOPMENT_HEADING_PATTERN = re.compile(r"^\s*(#{1,6})\s*(.*?)\s*$")
_DEVELOPMENT_SECTION_TITLE_PATTERN = re.compile(
r"(?:自测|测试|开发验证|离线验证|开发测试|离线测试)",
re.IGNORECASE,
)
_DEVELOPMENT_COMMAND_PATTERN = re.compile(
r"(?<![A-Za-z0-9_])check_contract\.py(?![A-Za-z0-9_])",
re.IGNORECASE,
)
_POLLUTION_RULES = (
(
"pytest_reference",
re.compile(r"(?<![A-Za-z0-9_])pytest(?![A-Za-z0-9_])", re.IGNORECASE),
"发现 pytest 测试入口",
),
(
"unittest_reference",
re.compile(r"(?<![A-Za-z0-9_])unittest(?![A-Za-z0-9_])", re.IGNORECASE),
"发现 unittest 测试入口",
),
(
"test_file_reference",
re.compile(
r"(?:\btest_[A-Za-z0-9][A-Za-z0-9_.-]*\.py\b"
r"|\b[A-Za-z0-9][A-Za-z0-9_.-]*_test\.py\b"
r"|test_\*\.py|\*_test\.py)",
re.IGNORECASE,
),
"发现测试文件路径或文件名模式",
),
(
"test_pass_declaration",
re.compile(
r"(?:"
r"(?:所有|全部)?\s*测试\s*(?:已|已经|均|都|全部)?\s*(?:通过|成功)"
r"|(?:通过|成功)\s*测试"
r"|(?:all|the)\s+tests?\s+(?:all\s+)?(?:pass(?:ed|es|ing)?|succeed(?:ed|s|ing)?|successful)"
r"|tests?\s+(?:all\s+)?(?:pass(?:ed|es|ing)?|succeed(?:ed|s|ing)?|successful)"
r")",
re.IGNORECASE,
),
"发现测试通过声明",
),
)
def _resolve_root(root: str | Path) -> Path:
"""把审计根目录解析为绝对路径,但不要求它已经存在。"""
path = Path(root).expanduser()
if not path.is_absolute():
path = Path.cwd() / path
return path.resolve()
def _relative_path(path: Path, root: Path) -> str:
"""返回报告中的稳定 POSIX 相对路径。"""
try:
return path.resolve().relative_to(root).as_posix()
except ValueError:
return path.resolve().as_posix()
def _issue(
code: str,
message: str,
*,
path: Optional[str] = None,
line: Optional[int] = None,
severity: str = "blocking",
**details: Any,
) -> dict[str, Any]:
result: dict[str, Any] = {
"code": code,
"severity": severity,
"message": message,
}
if path is not None:
result["path"] = path
if line is not None:
result["line"] = line
result.update(details)
return result
def _unquote_scalar(value: str) -> str:
"""解析 Skill frontmatter 中足够支持 name 的简单标量。"""
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] == '"':
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return value[1:-1]
return parsed if isinstance(parsed, str) else value
if len(value) >= 2 and value[0] == value[-1] == "'":
return value[1:-1].replace("''", "'")
# 允许常见的 YAML 行尾注释,不引入 YAML 依赖。
return re.sub(r"\s+#.*$", "", value).strip()
def _parse_frontmatter(text: str) -> tuple[Optional[dict[str, str]], Optional[int], Optional[str]]:
"""读取 frontmatter 的简单键值视图。
返回值为 ``(fields, closing_line, error_code)``。该解析器识别顶层标量键与
``|``/``>`` 块标量(description 需要),不试图替代完整 YAML 解析器;不合法或
缺失的边界会明确进入失败报告。
"""
lines = text.splitlines()
if not lines or lines[0].lstrip("\ufeff").strip() != "---":
return None, None, "frontmatter_missing"
closing_line: Optional[int] = None
for index in range(1, len(lines)):
if lines[index].strip() in {"---", "..."}:
closing_line = index + 1
break
if closing_line is None:
return None, None, "frontmatter_unclosed"
fields: dict[str, str] = {}
body = lines[1 : closing_line - 1]
index = 0
while index < len(body):
match = _FRONTMATTER_KEY.match(body[index])
if not match or match.group("indent"):
index += 1
continue
key = match.group("key")
value = match.group("value")
if value in {"|", ">", "|-", ">-", "|+", ">+"}:
block: list[str] = []
index += 1
while index < len(body) and (not body[index].strip() or body[index][:1] in " \t"):
block.append(body[index].strip())
index += 1
fields[key] = "\n".join(block).strip()
continue
fields[key] = _unquote_scalar(value)
index += 1
return fields, closing_line, None
def _snippet(line: str) -> str:
compact = line.strip()
return compact if len(compact) <= 200 else compact[:197] + "..."
def _is_development_coverage_reference(line: str) -> bool:
"""只把明确落在开发测试语境中的 coverage 文字判为污染。"""
if _COVERAGE_TOOL_PATTERN.search(line) or _EXPLICIT_TEST_COVERAGE_PATTERN.search(line):
return True
if not _COVERAGE_PERCENT_PATTERN.search(line):
return False
return not _BUSINESS_COVERAGE_CONTEXT_PATTERN.search(line)
def _inspect_skill_file(path: Path, root: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
relative = _relative_path(path, root)
entry: dict[str, Any] = {
"directory": path.parent.name,
"skill_path": relative,
"name": None,
"frontmatter_present": False,
"invocation": None,
"description": "",
"description_length": 0,
"line_count": 0,
"headings": [],
}
issues: list[dict[str, Any]] = []
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
issues.append(
_issue(
"skill_file_unreadable",
"无法读取 SKILL.md",
path=relative,
error=str(exc),
)
)
return entry, issues
if not text.strip():
issues.append(
_issue(
"empty_skill_file",
"SKILL.md 为空",
path=relative,
line=1,
)
)
return entry, issues
fields, closing_line, frontmatter_error = _parse_frontmatter(text)
if frontmatter_error is not None:
line = 1 if frontmatter_error == "frontmatter_missing" else max(1, len(text.splitlines()))
messages = {
"frontmatter_missing": "缺少以 --- 开始的 frontmatter",
"frontmatter_unclosed": "frontmatter 未闭合",
}
issues.append(
_issue(
frontmatter_error,
messages[frontmatter_error],
path=relative,
line=line,
)
)
else:
entry["frontmatter_present"] = True
fields = fields or {}
entry["invocation"] = (
"orchestrated" if "disable-model-invocation" in fields else "model_routed"
)
entry["description"] = fields.get("description", "")
entry["description_length"] = len(entry["description"])
name = fields.get("name")
entry["name"] = name or None
if not name:
issues.append(
_issue(
"frontmatter_name_missing",
"frontmatter 缺少非空 name",
path=relative,
line=1,
)
)
elif name != path.parent.name:
issues.append(
_issue(
"name_directory_mismatch",
"frontmatter name 与 Skill 目录名不一致",
path=relative,
line=2,
expected=path.parent.name,
actual=name,
)
)
development_section_level: Optional[int] = None
entry["line_count"] = len(text.splitlines())
for line_number, line in enumerate(text.splitlines(), start=1):
heading_match = _DEVELOPMENT_HEADING_PATTERN.match(line)
if heading_match:
heading_level = len(heading_match.group(1))
heading_title = re.sub(r"\s+#+\s*$", "", heading_match.group(2)).strip()
entry["headings"].append(heading_title)
if (
development_section_level is not None
and heading_level <= development_section_level
):
development_section_level = None
if (
2 <= heading_level <= 6
and _DEVELOPMENT_SECTION_TITLE_PATTERN.search(heading_title)
):
development_section_level = heading_level
issues.append(
_issue(
"development_test_heading",
"发现开发测试章节标题",
path=relative,
line=line_number,
snippet=_snippet(line),
)
)
for code, pattern, message in _POLLUTION_RULES:
if pattern.search(line):
issues.append(
_issue(
code,
message,
path=relative,
line=line_number,
snippet=_snippet(line),
)
)
if (
development_section_level is not None
and _DEVELOPMENT_COMMAND_PATTERN.search(line)
):
issues.append(
_issue(
"development_test_command_reference",
"发现开发测试章节中的合同测试命令",
path=relative,
line=line_number,
snippet=_snippet(line),
)
)
if _is_development_coverage_reference(line):
issues.append(
_issue(
"coverage_reference",
"发现开发测试语境中的覆盖率声明或工具名",
path=relative,
line=line_number,
snippet=_snippet(line),
)
)
return entry, issues
def _scan_skill_directories(root: Path) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
skills_root = root / SKILLS_ROOT
relative_skills_root = _relative_path(skills_root, root)
entries: list[dict[str, Any]] = []
issues: list[dict[str, Any]] = []
if not skills_root.exists():
issues.append(
_issue(
"skills_directory_missing",
"运行时 Skill 目录不存在",
path=relative_skills_root,
)
)
return entries, issues
if not skills_root.is_dir():
issues.append(
_issue(
"skills_directory_not_directory",
"运行时 Skill 路径不是目录",
path=relative_skills_root,
)
)
return entries, issues
try:
children = sorted(skills_root.iterdir(), key=lambda item: item.name)
except OSError as exc:
issues.append(
_issue(
"skills_directory_unreadable",
"无法列出运行时 Skill 目录",
path=relative_skills_root,
error=str(exc),
)
)
return entries, issues
directories = [child for child in children if child.is_dir()]
if not directories:
issues.append(
_issue(
"no_skills_found",
"运行时 Skill 目录中未发现 Skill 子目录",
path=relative_skills_root,
)
)
return entries, issues
for directory in directories:
skill_file = directory / "SKILL.md"
relative_skill_file = _relative_path(skill_file, root)
disk_entry: dict[str, Any] = {
"directory": directory.name,
"skill_path": relative_skill_file,
"name": None,
"frontmatter_present": False,
}
if not skill_file.exists():
entries.append(disk_entry)
issues.append(
_issue(
"missing_skill_file",
"Skill 目录缺少 SKILL.md",
path=relative_skill_file,
)
)
continue
if not skill_file.is_file():
entries.append(disk_entry)
issues.append(
_issue(
"skill_file_not_file",
"SKILL.md 路径不是普通文件",
path=relative_skill_file,
)
)
continue
entry, file_issues = _inspect_skill_file(skill_file, root)
entries.append(entry)
issues.extend(file_issues)
return entries, issues
def _resolve_manifest_path(root: Path, manifest: str | Path | None) -> Path:
path = DEFAULT_MANIFEST_PATH if manifest is None else Path(manifest).expanduser()
if not path.is_absolute():
path = root / path
return path.resolve()
def _normalise_manifest_skill_path(
root: Path, value: str
) -> tuple[Optional[str], Optional[Path]]:
declared = Path(value)
if declared.is_absolute():
return None, None
candidate = (root / declared).resolve()
try:
relative = candidate.relative_to(root).as_posix()
except ValueError:
return None, None
return relative, candidate
def _validate_manifest(
root: Path,
entries: Sequence[dict[str, Any]],
manifest_path: Path,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""验证 Skill 登记表,并要求它与磁盘目录一一对应。"""
relative_manifest = _relative_path(manifest_path, root)
metadata: dict[str, Any] = {
"path": relative_manifest,
"loaded": False,
"skills_declared": 0,
}
issues: list[dict[str, Any]] = []
if not manifest_path.exists():
issues.append(
_issue(
"manifest_missing",
"Skill manifest 不存在",
path=relative_manifest,
)
)
return metadata, issues
if not manifest_path.is_file():
issues.append(
_issue(
"manifest_not_file",
"Skill manifest 路径不是普通文件",
path=relative_manifest,
)
)
return metadata, issues
try:
manifest_text = manifest_path.read_text(encoding="utf-8")
manifest = json.loads(manifest_text)
except UnicodeError as exc:
issues.append(
_issue(
"manifest_unreadable",
"无法按 UTF-8 读取 Skill manifest",
path=relative_manifest,
error=str(exc),
)
)
return metadata, issues
except OSError as exc:
issues.append(
_issue(
"manifest_unreadable",
"无法读取 Skill manifest",
path=relative_manifest,
error=str(exc),
)
)
return metadata, issues
except json.JSONDecodeError as exc:
issues.append(
_issue(
"manifest_invalid_json",
"Skill manifest 不是合法 JSON",
path=relative_manifest,
line=exc.lineno,
column=exc.colno,
error=exc.msg,
)
)
return metadata, issues
metadata["loaded"] = True
if not isinstance(manifest, dict):
issues.append(
_issue(
"manifest_invalid_structure",
"Skill manifest 顶层必须是对象",
path=relative_manifest,
)
)
return metadata, issues
raw_skills = manifest.get("skills")
if not isinstance(raw_skills, list):
issues.append(
_issue(
"manifest_skills_invalid",
"Skill manifest.skills 必须是数组",
path=relative_manifest,
)
)
return metadata, issues
metadata["skills_declared"] = len(raw_skills)
disk_paths = {
str(entry["skill_path"])
for entry in entries
if isinstance(entry.get("skill_path"), str)
}
disk_invocation = {
str(entry["skill_path"]): entry.get("invocation")
for entry in entries
if isinstance(entry.get("skill_path"), str)
}
declared_paths: set[str] = set()
path_occurrences: dict[str, list[int]] = {}
name_occurrences: dict[str, list[int]] = {}
skills_root = (root / SKILLS_ROOT).resolve()
for index, item in enumerate(raw_skills):
if not isinstance(item, dict):
issues.append(
_issue(
"manifest_entry_invalid",
"Skill manifest 条目必须是对象",
path=relative_manifest,
entry_index=index,
)
)
continue
name = item.get("name")
if (
not isinstance(name, str)
or not name.strip()
or "/" in name
or "\\" in name
or name in {".", ".."}
):
issues.append(
_issue(
"manifest_name_invalid",
"manifest 条目的 name 必须是非空目录名",
path=relative_manifest,
entry_index=index,
actual=name,
)
)
else:
name_occurrences.setdefault(name, []).append(index)
lifecycle = item.get("lifecycle")
if lifecycle not in VALID_LIFECYCLES:
issues.append(
_issue(
"manifest_lifecycle_invalid",
"manifest 条目的 lifecycle 必须是作品创建周期九段之一",
path=relative_manifest,
entry_index=index,
actual=lifecycle,
allowed=list(VALID_LIFECYCLES),
)
)
compounding = item.get("compounding")
if compounding not in VALID_COMPOUNDING:
issues.append(
_issue(
"manifest_compounding_invalid",
"manifest 条目的 compounding 必须是 closed_loop、partial 或 none",
path=relative_manifest,
entry_index=index,
actual=compounding,
)
)
invocation = item.get("invocation")
if invocation not in VALID_INVOCATIONS:
issues.append(
_issue(
"manifest_invocation_invalid",
"manifest 条目的 invocation 必须是 orchestrated 或 model_routed",
path=relative_manifest,
entry_index=index,
actual=invocation,
)
)
invocation = None
side_effects = item.get("side_effects")
if (
not isinstance(side_effects, list)
or not side_effects
or any(value not in VALID_SIDE_EFFECTS for value in side_effects)
or ("none" in side_effects and len(side_effects) > 1)
):
issues.append(
_issue(
"manifest_side_effects_invalid",
"manifest 条目的 side_effects 必须是 none/db_write/external_call 的非空数组,且 none 不与其它值并列",
path=relative_manifest,
entry_index=index,
actual=side_effects,
)
)
# runtime 由领域表登记合同责任方;methodology 不进领域表,改以 domain 说明能力域。
# 两类 Skill 都在 AGENTS.md 第 3 节的领域表登记合同责任方。
contract_owner = item.get("contract_owner")
if not isinstance(contract_owner, str) or not contract_owner.strip():
issues.append(
_issue(
"manifest_contract_owner_invalid",
"manifest 条目的 contract_owner 必须非空",
path=relative_manifest,
entry_index=index,
)
)
collaborations = item.get("collaborates_with")
if (
not isinstance(collaborations, list)
or any(not isinstance(value, str) or not value.strip() for value in collaborations)
):
issues.append(
_issue(
"manifest_collaborates_with_invalid",
"manifest 条目的 collaborates_with 必须是非空字符串数组",
path=relative_manifest,
entry_index=index,
)
)
declared_skill_path = item.get("skill_path")
if not isinstance(declared_skill_path, str) or not declared_skill_path.strip():
issues.append(
_issue(
"manifest_skill_path_invalid",
"manifest 条目的 skill_path 必须是非空相对路径",
path=relative_manifest,
entry_index=index,
actual=declared_skill_path,
)
)
continue
normalised_path, candidate = _normalise_manifest_skill_path(
root, declared_skill_path
)
if normalised_path is None or candidate is None:
issues.append(
_issue(
"manifest_skill_path_invalid",
"manifest 条目的 skill_path 必须位于项目根目录内",
path=relative_manifest,
entry_index=index,
actual=declared_skill_path,
)
)
continue
declared_paths.add(normalised_path)
path_occurrences.setdefault(normalised_path, []).append(index)
# "不得由 Agent 自行触发"只有 orchestrated 才算生效;散文声明不能代替 frontmatter 开关。
actual_invocation = disk_invocation.get(normalised_path)
if invocation is not None and actual_invocation is not None and invocation != actual_invocation:
issues.append(
_issue(
"invocation_frontmatter_mismatch",
"manifest invocation 与 frontmatter 的 disable-model-invocation 不一致",
path=normalised_path,
entry_index=index,
expected=actual_invocation,
actual=invocation,
)
)
if candidate != candidate.parent / "SKILL.md" or not candidate.is_relative_to(skills_root):
issues.append(
_issue(
"manifest_skill_path_invalid",
"manifest 条目的 skill_path 必须指向 .agent/skills/*/SKILL.md",
path=normalised_path,
entry_index=index,
)
)
if not candidate.exists():
issues.append(
_issue(
"manifest_skill_path_missing",
"manifest 登记的 Skill 路径不存在",
path=normalised_path,
entry_index=index,
)
)
elif not candidate.is_file():
issues.append(
_issue(
"manifest_skill_path_not_file",
"manifest 登记的 Skill 路径不是普通文件",
path=normalised_path,
entry_index=index,
)
)
directory_name = candidate.parent.name
if isinstance(name, str) and name.strip() and name != directory_name:
issues.append(
_issue(
"manifest_name_directory_mismatch",
"manifest name 与 skill_path 的目录名不一致",
path=normalised_path,
entry_index=index,
expected=directory_name,
actual=name,
)
)
if isinstance(name, str) and name.strip():
expected_path = (root / SKILLS_ROOT / name / "SKILL.md").resolve()
expected_relative = _relative_path(expected_path, root)
if normalised_path != expected_relative:
issues.append(
_issue(
"manifest_skill_path_mismatch",
"manifest skill_path 与 name 对应的 Skill 路径不一致",
path=normalised_path,
entry_index=index,
expected=expected_relative,
actual=normalised_path,
)
)
for path, indexes in sorted(path_occurrences.items()):
if len(indexes) > 1:
issues.append(
_issue(
"manifest_duplicate_skill_path",
"manifest 重复登记同一个 Skill 路径",
path=path,
entry_indexes=indexes,
)
)
for name, indexes in sorted(name_occurrences.items()):
if len(indexes) > 1:
issues.append(
_issue(
"manifest_duplicate_name",
"manifest 重复登记同一个 Skill name",
path=relative_manifest,
name=name,
entry_indexes=indexes,
)
)
for path in sorted(disk_paths - declared_paths):
issues.append(
_issue(
"manifest_skill_missing",
"磁盘 Skill 未在 manifest 中登记",
path=path,
)
)
for path in sorted(declared_paths - disk_paths):
issues.append(
_issue(
"manifest_skill_extra",
"manifest 登记了磁盘中不存在的额外 Skill",
path=path,
)
)
return metadata, issues
def _load_manifest_classes(manifest_path: Path, root: Path) -> dict[str, dict[str, Any]]:
"""只取评分需要的分类视图;manifest 本身的合法性由 :func:`_validate_manifest` 判定。"""
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return {}
if not isinstance(manifest, dict) or not isinstance(manifest.get("skills"), list):
return {}
view: dict[str, dict[str, Any]] = {}
for item in manifest["skills"]:
if not isinstance(item, dict):
continue
declared = item.get("skill_path")
if not isinstance(declared, str):
continue
normalised, _ = _normalise_manifest_skill_path(root, declared)
if normalised is None:
continue
view[normalised] = {
"lifecycle": item.get("lifecycle"),
"side_effects": item.get("side_effects") or [],
"compounding": item.get("compounding"),
}
return view
def _collect_markdown_stems(root: Path) -> set[str]:
"""收集审计根目录下全部 Markdown 文件的裸名,用于判定代号引用是否落空。"""
stems: set[str] = set()
for path in root.rglob("*.md"):
if any(part in _SCAN_SKIP_DIRS for part in path.parts):
continue
stems.add(path.stem)
return stems
def _skill_documents(skill_dir: Path) -> list[Path]:
"""一个 Skill 包内会被人或模型读到的 Markdown 文档。"""
documents: list[Path] = []
skill_file = skill_dir / "SKILL.md"
if skill_file.is_file():
documents.append(skill_file)
for sub in ("references", "scripts"):
directory = skill_dir / sub
if not directory.is_dir():
continue
for suffix in _DOC_SUFFIXES:
documents.extend(sorted(directory.rglob(f"*{suffix}")))
return documents
def _document_advisories(
root: Path, skill_dir: Path, stems: set[str]
) -> list[dict[str, Any]]:
"""逐文档检查落空的内部代号引用与不可解析的文档路径。"""
advisories: list[dict[str, Any]] = []
for document in _skill_documents(skill_dir):
try:
text = document.read_text(encoding="utf-8")
except (OSError, UnicodeError):
continue
relative = _relative_path(document, root)
for line_number, line in enumerate(text.splitlines(), start=1):
for codename in _CODENAME_CITATION.findall(line):
if codename in _CODENAME_ALLOWLIST or codename in stems:
continue
advisories.append(
_issue(
"dangling_internal_codename",
f"引用了不存在的内部代号文档:{codename}",
path=relative,
line=line_number,
severity="major",
codename=codename,
snippet=_snippet(line),
)
)
targets = list(_MARKDOWN_LINK.findall(line)) + list(
_INLINE_DOC_PATH.findall(line)
)
for target in targets:
resolved = _resolve_doc_target(document, target)
if resolved is None or resolved.exists():
continue
advisories.append(
_issue(
"reference_target_missing",
f"引用的文档路径不存在:{target}",
path=relative,
line=line_number,
severity="major",
target=target,
)
)
return advisories
def _resolve_doc_target(document: Path, target: str) -> Optional[Path]:
"""把文档内引用解析为磁盘路径;外链与纯锚点返回 None 表示不判定。"""
candidate = target.strip().strip("<>")
if not candidate or candidate.startswith(("#", "http://", "https://", "mailto:")):
return None
candidate = candidate.split("#", 1)[0].strip()
if not candidate or candidate.startswith("/"):
return None
return (document.parent / candidate).resolve()
def _reference_hygiene_advisories(
root: Path, skill_dir: Path, skill_path: str
) -> list[dict[str, Any]]:
"""references/ 的孤儿文件与超大单文件。"""
advisories: list[dict[str, Any]] = []
references = skill_dir / "references"
if not references.is_dir():
return advisories
skill_file = skill_dir / "SKILL.md"
try:
skill_text = skill_file.read_text(encoding="utf-8")
except (OSError, UnicodeError):
skill_text = ""
for path in sorted(references.rglob("*.md")):
relative = _relative_path(path, root)
try:
size = path.stat().st_size
except OSError:
size = 0
if size > REFERENCE_FILE_BUDGET_BYTES:
advisories.append(
_issue(
"reference_file_too_large",
f"单个 references 文件 {size // 1024} KB 超出 "
f"{REFERENCE_FILE_BUDGET_BYTES // 1024} KB 上限,应按主题拆分",
path=relative,
severity="minor",
budget=REFERENCE_FILE_BUDGET_BYTES,
actual=size,
)
)
# 下划线开头是包内记账文件(如 _coverage.md),不是可加载参考。
if path.parent != references or path.name.startswith("_"):
continue
if path.name not in skill_text:
advisories.append(
_issue(
"orphan_reference_file",
f"references/{path.name} 未被 SKILL.md 引用,属包内死重",
path=relative,
severity="major",
skill_path=skill_path,
)
)
return advisories
def _trigger_tokens(description: str) -> set[str]:
"""取 description 中“何时用”那半段的触发词;排除项之后不计入。"""
head = description.split(_DESCRIPTION_EXCLUSION_MARKER, 1)[0].replace("’", "'")
tokens = set()
for raw in _TRIGGER_SPLIT.split(head):
token = raw.strip().strip(".-_")
if not token or token in _TRIGGER_STOPWORDS:
continue
if _TRIGGER_MIN_LEN <= len(token) <= _TRIGGER_MAX_LEN:
tokens.add(token)
return tokens
def _shared_triggers(left: set[str], right: set[str]) -> list[str]:
"""重合的触发说法:完全相同,或一方把另一方整句包进更长的说法里。"""
shared = set(left & right)
for token in left:
if len(token) < _TRIGGER_CONTAINMENT_MIN_LEN:
continue
if any(token in other for other in right if other != token):
shared.add(token)
for token in right:
if len(token) < _TRIGGER_CONTAINMENT_MIN_LEN:
continue
if any(token in other for other in left if other != token):
shared.add(token)
return sorted(shared)
def _cross_skill_advisories(
root: Path,
entries: Sequence[dict[str, Any]],
classes: dict[str, dict[str, Any]],
) -> list[dict[str, Any]]:
"""集合级检查:点名不存在的 Skill、同周期段触发词碰撞、案例范本跨 Skill 重复。"""
advisories: list[dict[str, Any]] = []
known = {
str(entry.get("directory"))
for entry in entries
if isinstance(entry.get("directory"), str)
}
routed: list[tuple[str, str, str, str, set[str]]] = []
for entry in entries:
skill_path = str(entry.get("skill_path", ""))
declared = classes.get(skill_path)
if declared is None:
continue
directory = str(entry.get("directory", ""))
description = str(entry.get("description", ""))
package_docs = {
path.stem
for path in (root / SKILLS_ROOT / directory).rglob("*.md")
}
for token in sorted(set(_SKILL_NAME_TOKEN.findall(description))):
# 指向自己包内 references/ 文档的连字符名不是路由目标,不算点名。
if token in known or token in _KNOWN_NON_SKILL_TOKENS or token in package_docs:
continue
advisories.append(
_issue(
"description_names_unknown_skill",
f"description 点名的 {token} 不是现存 Skill,模型无法按它路由",
path=skill_path,
severity="major",
token=token,
)
)
routed.append(
(
skill_path,
directory,
str(declared.get("lifecycle") or ""),
description,
_trigger_tokens(description),
)
)
for index, left in enumerate(routed):
for right in routed[index + 1 :]:
if not left[2] or left[2] != right[2]:
continue
shared = _shared_triggers(left[4], right[4])
if not shared:
continue
if right[1] in left[3] or left[1] in right[3]:
continue
advisories.append(
_issue(
"trigger_collision_without_handoff",
f"与 {right[1]} 同处 {left[2]} 周期段且触发词重合"
f"({'、'.join(shared)}),双方都没点名对方",
path=left[0],
severity="major",
peer=right[1],
shared_triggers=shared,
)
)
return advisories
def _exemplar_advisories(root: Path, entries: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
"""同一案例范本被两个 Skill 各写一遍:出范式卡时会重复登记同一条方法。"""
occurrences: dict[str, list[tuple[str, str, int]]] = {}
for entry in entries:
directory = str(entry.get("directory", ""))
skill_dir = root / SKILLS_ROOT / directory
for document in _skill_documents(skill_dir):
try:
text = document.read_text(encoding="utf-8")
except (OSError, UnicodeError):
continue
relative = _relative_path(document, root)
for line_number, line in enumerate(text.splitlines(), start=1):
match = _EXEMPLAR_HEADING.match(line)
if not match:
continue
occurrences.setdefault(match.group(1), []).append(
(directory, relative, line_number)
)
advisories: list[dict[str, Any]] = []
for exemplar, hits in sorted(occurrences.items()):
owners = sorted({directory for directory, _, _ in hits})
if len(owners) < 2:
continue
for directory, relative, line_number in hits:
peers = [owner for owner in owners if owner != directory]
advisories.append(
_issue(
"shared_exemplar_heading",
f"案例范本「{exemplar}」同时出现在 {'、'.join(peers)},"
"应只留一份、其余改链接",
path=relative,
line=line_number,
severity="minor",
exemplar=exemplar,
owners=owners,
)
)
return advisories
def _quality_advisories(
root: Path,
entries: Sequence[dict[str, Any]],
classes: dict[str, dict[str, Any]],
) -> list[dict[str, Any]]:
"""产出不阻断退出码的质量发现,评分口径见 specs/skill-quality-rubric.md。"""
advisories: list[dict[str, Any]] = []
stems = _collect_markdown_stems(root)
for entry in entries:
skill_path = str(entry.get("skill_path", ""))
declared = classes.get(skill_path)
if declared is None or declared["lifecycle"] not in VALID_LIFECYCLES:
continue
skill_dir = root / SKILLS_ROOT / str(entry.get("directory", ""))
advisories.extend(_document_advisories(root, skill_dir, stems))
advisories.extend(_reference_hygiene_advisories(root, skill_dir, skill_path))
lifecycle = str(declared["lifecycle"])
side_effects = declared["side_effects"]
compounding = declared["compounding"]
required = list(_BASE_SECTIONS)
if side_effects != ["none"]:
required.extend(_SIDE_EFFECT_SECTIONS)
if compounding in ("closed_loop", "partial"):
required.extend(_COMPOUNDING_SECTIONS)
headings = [str(value) for value in entry.get("headings", [])]
for section in required:
pattern = _SECTION_PATTERNS[section]
if not any(pattern.search(heading) for heading in headings):
advisories.append(
_issue(
"required_section_missing",
f"缺少必备节:{section}",
path=skill_path,
severity="major",
section=section,
)
)
line_count = int(entry.get("line_count", 0))
if line_count > LINE_BUDGET:
advisories.append(
_issue(
"line_budget_exceeded",
f"正文 {line_count} 行超出预算 {LINE_BUDGET} 行",
path=skill_path,
severity="major" if line_count > LINE_BUDGET * 1.5 else "minor",
budget=LINE_BUDGET,
actual=line_count,
)
)
if entry.get("invocation") == "model_routed":
desc_budget = (
WIDE_DESCRIPTION_BUDGET
if lifecycle in WIDE_DESCRIPTION_LIFECYCLES
else DESCRIPTION_BUDGET
)
desc_length = int(entry.get("description_length", 0))
if desc_length > desc_budget:
advisories.append(
_issue(
"description_budget_exceeded",
f"description {desc_length} 字符超出预算 {desc_budget} 字符",
path=skill_path,
severity="major" if desc_length > desc_budget * 1.5 else "minor",
budget=desc_budget,
actual=desc_length,
)
)
# 复利未接入是待补齐的债,不是合法终态;平台底座不承载创作经验,豁免。
if compounding == "none" and lifecycle != "platform":
advisories.append(
_issue(
"compounding_not_wired",
"未接入经验复利:方法与判断只活在 SKILL.md 与 references/,用过之后不留下可被后续消费的资产",
path=skill_path,
severity="major",
lifecycle=lifecycle,
)
)
scripts_dir = root / SKILLS_ROOT / str(entry.get("directory", "")) / "scripts"
if scripts_dir.is_dir():
try:
names = sorted(item.name for item in scripts_dir.iterdir() if item.is_file())
except OSError:
names = []
tests = [name for name in names if _SCRIPT_TEST_PATTERN.match(name)]
implementations = [
name for name in names if name.endswith(".py") and name not in tests
]
for name in tests:
advisories.append(
_issue(
"scripts_test_file",
"scripts/ 内存放测试文件,应移入 tests/skills/<skill>/",
path=f"{skill_path.rsplit('/', 1)[0]}/scripts/{name}",
severity="major",
)
)
if not implementations:
others = [name for name in names if name not in tests]
message = (
f"scripts/ 只有非可执行文件({'、'.join(others)}),清单与模板类内容应放 references/"
if others
else "scripts/ 目录为空,应补运行时实现或删除该目录"
)
advisories.append(
_issue(
"scripts_without_implementation",
message,
path=f"{skill_path.rsplit('/', 1)[0]}/scripts",
severity="major",
files=others,
)
)
advisories.extend(_cross_skill_advisories(root, entries, classes))
advisories.extend(_exemplar_advisories(root, entries))
return advisories
def _duplicate_name_issues(entries: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
by_name: dict[str, list[str]] = {}
for entry in entries:
name = entry.get("name")
if isinstance(name, str) and name:
by_name.setdefault(name, []).append(str(entry["skill_path"]))
issues: list[dict[str, Any]] = []
for name in sorted(by_name):
paths = sorted(by_name[name])
if len(paths) > 1:
issues.append(
_issue(
"duplicate_name",
"多个 Skill 使用相同的 frontmatter name",
path=paths[0],
name=name,
paths=paths,
)
)
return issues
def _summarize(
issues: Sequence[dict[str, Any]],
advisories: Sequence[dict[str, Any]],
skill_count: int,
*,
ok: bool,
) -> dict[str, Any]:
counts = Counter(str(issue["code"]) for issue in issues)
advisory_counts = Counter(str(item["code"]) for item in advisories)
severities = Counter(str(item["severity"]) for item in advisories)
return {
"status": "passed" if ok else "failed",
"skills_scanned": skill_count,
"issue_count": len(issues),
"issue_codes": {code: counts[code] for code in sorted(counts)},
"advisory_count": len(advisories),
"advisory_codes": {code: advisory_counts[code] for code in sorted(advisory_counts)},
"advisory_severities": {name: severities[name] for name in sorted(severities)},
}
def audit_skills(
root: str | Path = ".",
manifest: str | Path | None = None,
*,
strict: bool = False,
) -> dict[str, Any]:
"""审计 Skill 文档与 manifest,并返回 JSON 可序列化报告。
阻断级问题进 ``issues`` 并决定退出码;严重与一般级发现进 ``advisories``,
只有 ``strict=True`` 时才一并阻断。
"""
root_path = _resolve_root(root)
manifest_path = _resolve_manifest_path(root_path, manifest)
report: dict[str, Any] = {
"schema_version": SCHEMA_VERSION,
"root": str(root_path),
"skills_root": SKILLS_ROOT.as_posix(),
"manifest": {
"path": _relative_path(manifest_path, root_path),
"loaded": False,
},
"skills_scanned": 0,
"skills": [],
"issues": [],
"advisories": [],
}
if not root_path.exists():
report["issues"] = [
_issue("root_missing", "审计根目录不存在", path=".")
]
elif not root_path.is_dir():
report["issues"] = [
_issue("root_not_directory", "审计根路径不是目录", path=".")
]
else:
entries, issues = _scan_skill_directories(root_path)
issues.extend(_duplicate_name_issues(entries))
manifest_info, manifest_issues = _validate_manifest(
root_path, entries, manifest_path
)
issues.extend(manifest_issues)
report["manifest"] = manifest_info
report["skills"] = entries
report["skills_scanned"] = len(entries)
report["issues"] = issues
findings = _quality_advisories(
root_path, entries, _load_manifest_classes(manifest_path, root_path)
)
issues.extend(
item for item in findings if item["code"] in _BLOCKING_ADVISORY_CODES
)
report["advisories"] = [
item for item in findings if item["code"] not in _BLOCKING_ADVISORY_CODES
]
report["issue_count"] = len(report["issues"])
report["advisory_count"] = len(report["advisories"])
report["ok"] = not report["issues"] and not (strict and report["advisories"])
report["status"] = "passed" if report["ok"] else "failed"
report["strict"] = strict
report["summary"] = _summarize(
report["issues"], report["advisories"], report["skills_scanned"], ok=report["ok"]
)
return report
def human_summary(report: dict[str, Any]) -> str:
"""把结构化报告渲染为简洁的人读摘要。"""
status = "通过" if report["ok"] else "失败"
lines = [
f"Skill 静态审计:{status}",
f"扫描 Skill:{report['skills_scanned']} 个;"
f"阻断问题:{report['issue_count']} 个;"
f"质量发现:{report.get('advisory_count', 0)} 个",
]
def render(items: Sequence[dict[str, Any]]) -> None:
for item in items:
location = str(item.get("path", "<root>"))
if item.get("line") is not None:
location += f":{item['line']}"
lines.append(f"- [{item['severity']}] {location} [{item['code']}] {item['message']}")
if report["issues"]:
lines.append("阻断问题:")
render(report["issues"])
else:
lines.append("未发现分类、frontmatter、目录命名、空文件或开发测试污染的阻断问题。")
if report.get("advisories"):
lines.append("质量发现(默认不阻断,评分口径见 specs/skill-quality-rubric.md):")
render(report["advisories"])
return "\n".join(lines)
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="只读扫描项目运行时 .agent/skills/*/SKILL.md 的静态卫生审计器。"
)
parser.add_argument(
"--root",
default=".",
help="项目根目录,默认为当前目录",
)
parser.add_argument(
"--manifest",
default=None,
help="Skill manifest 路径;相对路径按项目根目录解析,默认 harness/manifests/skills.json",
)
parser.add_argument(
"--json",
action="store_true",
help="输出结构化 JSON 报告",
)
parser.add_argument(
"--quiet",
action="store_true",
help="不输出人读摘要;与 --json 同用时仍输出 JSON",
)
parser.add_argument(
"--strict",
action="store_true",
help="把严重与一般级质量发现一并计入退出码;存量清理完成后应长期开启",
)
return parser
def main(argv: Optional[Sequence[str]] = None) -> int:
args = _build_parser().parse_args(argv)
report = audit_skills(args.root, args.manifest, strict=args.strict)
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
elif not args.quiet:
print(human_summary(report))
return 0 if report["ok"] else 1
if __name__ == "__main__":
sys.exit(main())