Files
STM32F4-Base/scripts/check_skill_size.py
T
wandering1 c63bd029a9 feat(门禁): 新增 skill-size 钩子——SKILL.md 不得超插件写侧上限 65536
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 指南带日期实测行只加注

本笔先落地,技能正文的渐进展开拆分在下一笔。
2026-10-29 15:02:23 +08:00

209 lines
9.3 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
# -*- 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())