#!/usr/bin/env python3 # -*- coding: utf-8 -*- """SKILL.md 体积与指针门禁(skill-size)。 为什么要有这根(实测 2026-10-29,两个技能同日各卡死一次):dsh-mnemon 插件对技能正文有 **65536 字节的写侧硬上限**(``validateSkillContent()``),但它**只在写侧校验、加载侧不查**—— 超限的 SKILL.md 照常 ``skill()`` 可读、照常进技能目录,唯独自进化每次「生成草案」都在落盘那一步 被拒:那一行永久卡在「生成中」(drafting)、界面只剩转圈、连「忽略」都不出现(Draft/Dismiss 只在 pending 渲染)。换句话说:**文件能读、能改、能提交,仓库里没有任何一处会告诉你这个技能已经废了**, 直到下一次卡死、白烧一个 30 分钟的草案 Agent。本门禁把这条隐性约束变成提交时的显式判定。 判据: 1. `.dsh/skills//SKILL.md` 的 utf-8 字节数 > 65536 → FAIL(与插件写侧上限同数、同口径: 字节数不是字符数)。 2. 字节数 >= 49152(上限的 75%)→ WARN,不拦提交。草案是在这个区间里被"下一次采纳"顶过线的 (实测 49821 → 62806 只隔一次采纳),提前一格留头量比卡死后再拆便宜。 3. 正文里 `(reference/xxx.md)` 指向的文件不存在 → FAIL。渐进展开的前提是"指针能兑现"; 悬空指针等于把规程删了却还写着"见那篇",比内联更坏。 退出码:0 = 通过(可有 WARN);1 = 有 FAIL;2 = 用法/环境错误(根目录不存在、不在本仓库内)。 纯标准库,任意 python3 均可(不吃 .venv 的第三方包,所以钩子 entry 用裸 python 也安全)。 """ from __future__ import annotations import argparse import os import re import sys import tempfile CAP_BYTES = 65536 # 与 dsh-mnemon MAX_SKILL_BYTES 同数 WARN_BYTES = 49152 # CAP 的 75% SKILLS_SUBDIR = os.path.join(".dsh", "skills") LINK_RE = re.compile(r"\(reference/([A-Za-z0-9._\-]+\.md)\)") def _utf8_stdout() -> None: for stream in (sys.stdout, sys.stderr): try: stream.reconfigure(encoding="utf-8", errors="replace") except Exception: pass def find_skills_dir(start: str): """从 start 往上找含 .dsh/skills 的目录(钩子从仓库根跑,手工跑时可能在子目录)。""" cur = os.path.abspath(start) for _ in range(12): cand = os.path.join(cur, SKILLS_SUBDIR) if os.path.isdir(cand): return cand parent = os.path.dirname(cur) if parent == cur: break cur = parent return None def rel(p: str) -> str: try: return os.path.relpath(p, os.getcwd()).replace(os.sep, "/") except Exception: return p.replace(os.sep, "/") def check_tree(skills_dir: str, verbose: bool = False): """返回 (fails, warns, checked)。fails/warns 是可直接打印的行。""" fails = [] warns = [] checked = 0 for name in sorted(os.listdir(skills_dir)): d = os.path.join(skills_dir, name) skill_md = os.path.join(d, "SKILL.md") if not os.path.isfile(skill_md): continue checked += 1 with open(skill_md, "rb") as fh: raw = fh.read() size = len(raw) if size > CAP_BYTES: fails.append( "FAIL " + rel(skill_md) + " " + str(size) + " 字节 > 写侧上限 " + str(CAP_BYTES) + "(超 " + str(size - CAP_BYTES) + "):这个技能的自进化每次「生成草案」都会被拒、" "永久卡在「生成中」。把深规程逐字搬进同目录 reference/<篇名>.md 并留指针" "(别重排既有步骤号);已卡死的台账行按 mnemon-skill-ledger-stuck-repair 技能处理。" ) elif size >= WARN_BYTES: warns.append( "WARN " + rel(skill_md) + " " + str(size) + " 字节 = 上限的 " + str(size * 100 // CAP_BYTES) + "%(阈值 " + str(WARN_BYTES) + "):再吃一次草案采纳就可能越线,建议先把某一节外置到 reference/。" ) if raw.startswith(b"\xef\xbb\xbf"): fails.append("FAIL " + name + ": SKILL.md 带 UTF-8 BOM(插件按 utf-8 解析,frontmatter 会读歪)") text = raw.decode("utf-8", errors="replace") for target in sorted(set(LINK_RE.findall(text))): if not os.path.isfile(os.path.join(d, "reference", target)): fails.append("FAIL " + name + ": 指针 reference/" + target + " 悬空——正文引用了它,文件不在。" "要么补文件,要么把指针连同它承诺的内容一起搬回正文。") if verbose: print(" ok " + name + " " + str(size) + " 字节 (" + str(size * 100 // CAP_BYTES) + "%)") return fails, warns, checked def run(paths, roots_extra, verbose: bool = False) -> int: """paths:钩子传进来的文件(只用于触发;判定始终全树扫,这样只改 reference/*.md 也照样量体积)。""" dirs = [] for r in roots_extra: if not os.path.isdir(r): print("ERROR --root 不存在: " + r, file=sys.stderr) return 2 skills = r if os.path.basename(r) == "skills" else os.path.join(r, SKILLS_SUBDIR) if not os.path.isdir(skills): print("WARN 跳过 " + r + ":没有 " + SKILLS_SUBDIR.replace(os.sep, "/")) continue dirs.append(skills) if not dirs: base = find_skills_dir(os.getcwd()) if base is None: print("ERROR 找不到 .dsh/skills(不在本仓库内?用 --root 指过去)", file=sys.stderr) return 2 dirs.append(base) all_fail = [] all_warn = [] total = 0 for d in dirs: fails, warns, checked = check_tree(d, verbose=verbose) all_fail += fails all_warn += warns total += checked if verbose or fails or warns: print("== " + d.replace(os.sep, "/") + ":SKILL.md " + str(checked) + " 个") for line in all_warn: print(line) for line in all_fail: print(line) if all_fail: print("") print("skill-size:" + str(len(all_fail)) + " 项不合格 / 共扫 " + str(total) + " 个 SKILL.md(上限 " + str(CAP_BYTES) + " 字节)") return 1 tail = "(" + str(len(all_warn)) + " 条 WARN)" if all_warn else ",无悬空指针" print("skill-size:" + str(total) + " 个 SKILL.md 全在写侧上限内" + tail) return 0 def _make(tmp: str, skill: str, size: int, link=None, bom: bool = False) -> None: d = os.path.join(tmp, SKILLS_SUBDIR, skill) os.makedirs(os.path.join(d, "reference"), exist_ok=True) body = "---\nname: t\ndescription: d\n---\n\n# t\n\n" # 先把正文垫到恰好 size 字节(链接行加在 padding 之后,所以带链接的用例只多不少) text = body + ("x" * max(0, size - len(body.encode("utf-8")))) if link: text += "\nsee [x](reference/" + link + ").\n" data = text.encode("utf-8") if bom: data = b"\xef\xbb\xbf" + data with open(os.path.join(d, "SKILL.md"), "wb") as fh: fh.write(data) def selftest() -> int: """在临时目录造四种形态,验判定真有牙(不是"永远绿")。""" ok = True with tempfile.TemporaryDirectory() as tmp: _make(tmp, "clean", 2000, link="present.md") with open(os.path.join(tmp, SKILLS_SUBDIR, "clean", "reference", "present.md"), "w") as fh: fh.write("# p\n") rc = run([], [tmp]) ok = ok and rc == 0 print("selftest 干净树 -> " + str(rc) + "(期望 0)") _make(tmp, "warnish", WARN_BYTES + 10) rc = run([], [tmp]) ok = ok and rc == 0 print("selftest 75% 线 -> " + str(rc) + "(期望 0 带 WARN)") _make(tmp, "fat", CAP_BYTES + 10) rc = run([], [tmp]) ok = ok and rc == 1 print("selftest 超上限 -> " + str(rc) + "(期望 1)") os.remove(os.path.join(tmp, SKILLS_SUBDIR, "fat", "SKILL.md")) os.remove(os.path.join(tmp, SKILLS_SUBDIR, "warnish", "SKILL.md")) _make(tmp, "dangling", 3000, link="gone.md") _make(tmp, "bommed", 3000, bom=True) rc = run([], [tmp]) ok = ok and rc == 1 print("selftest 悬空指针/BOM -> " + str(rc) + "(期望 1)") rc = run([], [os.path.join(tmp, "nope")]) ok = ok and rc == 2 print("selftest 根不存在 -> " + str(rc) + "(期望 2)") print("SELFTEST " + ("PASS" if ok else "FAIL")) return 0 if ok else 1 def main(argv=None) -> int: _utf8_stdout() ap = argparse.ArgumentParser(description="SKILL.md 体积与 reference/ 指针门禁") ap.add_argument("paths", nargs="*", help="钩子传入的变更文件(仅用于触发;判定始终全树扫)") ap.add_argument("--root", action="append", default=[], help="额外要扫的根(可重复),其下需有 .dsh/skills") ap.add_argument("--selftest", action="store_true", help="在临时目录造形态自证判定有牙") ap.add_argument("--verbose", action="store_true", help="逐个打印体积") args = ap.parse_args(argv) if args.selftest: return selftest() return run(args.paths, args.root, verbose=args.verbose) if __name__ == "__main__": sys.exit(main())