c63bd029a9
dsh-mnemon 的 MAX_SKILL_BYTES = 65536 只在写侧校验,加载侧从不检查, 所以正文越界的技能照常加载、却永远生成不出草案:同日两次卡死 (83194 字节与 72601 字节)都是这个形态,复位台账无效,只能瘦身。 故把上限机械化,越界与悬空指针在提交期就拦下。 - scripts/check_skill_size.py:纯标准库(裸 python 也能跑,故不解析 .venv);FAIL >65536、FAIL reference/ 指针悬空、FAIL UTF-8 BOM、 WARN ≥49152(75%——实测一次「采纳」把主文从 49821 顶到 62806); 总是全树扫,只拿 reference/x.md 触发的提交正是漏网场景; --root 可复指到用户级 ~/.dsh/skills;--selftest 五形态(干净 0 / 临界 0+WARN / 超限 1 / 悬空+BOM 1 / 根不存在 2)全过 - .pre-commit-config.yaml 加 skill-size,files: ^\.dsh/skills/ - 新增 docs/门禁说明/skill-size.md,含盲区:拦不住插件自身的采纳写盘、 用户级技能目录不在触发面、不解析 frontmatter、不查 U+FFFD - 计数同步 51 -> 52(实跑 48 -> 49,索引 28 -> 29 篇):AGENTS.md 与 门禁索引,另 7 处文档旧计数改写、CI 指南带日期实测行只加注 本笔先落地,技能正文的渐进展开拆分在下一笔。
209 lines
9.3 KiB
Python
209 lines
9.3 KiB
Python
#!/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/<name>/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())
|