feat(pouch): add skill structure audit to cut token waste
Teach pouch to optimize a named skill's loading layout: keep SKILL.md as a router, move mode-specific steps to references, and measure footprint with an audit script instead of dumping the whole skill into context.
This commit is contained in:
@@ -57,6 +57,8 @@ description: >-
|
|||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- 约束或边界条件
|
- 约束或边界条件
|
||||||
|
- `SKILL.md` 只留模式路由、全模式安全边界,以及「若 X 则读 `references/Y.md`」。
|
||||||
|
单模式步骤不要写进正文。
|
||||||
|
|
||||||
## 验证
|
## 验证
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
- 想完善、校验、转正或发布已有 skill。
|
- 想完善、校验、转正或发布已有 skill。
|
||||||
- 想把自研 skill 安装到当前项目或全局 Agent。
|
- 想把自研 skill 安装到当前项目或全局 Agent。
|
||||||
- 想检查并修复 skill 软链接。
|
- 想检查并修复 skill 软链接。
|
||||||
|
- 想优化某个已有 skill 的结构,减少点中后灌进上下文的内容。
|
||||||
|
|
||||||
## 创建一个 skill
|
## 创建一个 skill
|
||||||
|
|
||||||
@@ -31,6 +32,18 @@ pouch check my-skill
|
|||||||
pouch finalize my-skill
|
pouch finalize my-skill
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 优化已有 skill 的结构
|
||||||
|
|
||||||
|
skill 能用但正文太长、所有模式写在一份 `SKILL.md` 里,或点中后把用不到的参考一并读进上下文时:
|
||||||
|
|
||||||
|
```text
|
||||||
|
pouch 帮忙优化 ack 这个 skill 结构,减少 token 浪费
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent 会先测量该 skill 的体积,再按分层加载改 SSOT:`SKILL.md` 只留路由和全模式边界,细节按条件读 `references/`。不会为了缩字删掉发版/部署一类的安全限制。改完仍需 `pouch check` 通过。
|
||||||
|
|
||||||
|
不要对社区 catalog skill 的安装目录直接改;那些不是本仓库的 SSOT。
|
||||||
|
|
||||||
## 提交和发布
|
## 提交和发布
|
||||||
|
|
||||||
只提交:
|
只提交:
|
||||||
@@ -64,4 +77,5 @@ pouch add my-skill -a codex -g -y
|
|||||||
- `pouch check <name>` 输出校验通过。
|
- `pouch check <name>` 输出校验通过。
|
||||||
- 正式 skill 同时包含 `SKILL.md` 和 `README.md`。
|
- 正式 skill 同时包含 `SKILL.md` 和 `README.md`。
|
||||||
- `pouch status` 或 `pouch doctor` 显示目标软链接正常。
|
- `pouch status` 或 `pouch doctor` 显示目标软链接正常。
|
||||||
|
- 优化结构时:审计报告有前后体积,安全限制仍在 `SKILL.md`,没有为缩字删掉模式。
|
||||||
|
|
||||||
|
|||||||
+34
-5
@@ -1,16 +1,26 @@
|
|||||||
---
|
---
|
||||||
name: pouch
|
name: pouch
|
||||||
description: >-
|
description: >-
|
||||||
创建和维护 ~/.pouch 自研 skill:把项目开发中产生的想法提炼为草稿,完善并校验后发布,
|
创建、校验、转正、安装或发布 ~/.pouch 自研 skill,初始化 skill 项目状态,
|
||||||
用 pouch add/remove 在项目及全局挂卸 skill,或用 pouch init 初始化 skill 项目状态。
|
或优化 skill 分层加载结构以减少 token 浪费。
|
||||||
触发词:pouch、skiff、自研 skill、创建 skill、想做一个 skill、publish skill、安装自研 skill、
|
触发词:pouch、skiff、自研 skill、创建 skill、publish skill、安装自研 skill、
|
||||||
更新 skill 到项目、初始化 skill。
|
初始化 skill、优化 skill 结构、减少 token 浪费、skill 太长、分层加载。
|
||||||
---
|
---
|
||||||
|
|
||||||
# pouch 自研 Skill 工作流
|
# pouch 自研 Skill 工作流
|
||||||
|
|
||||||
SSOT 固定在 `~/.pouch/skills/<name>/`。内容通过 **symlink** 分发到各 agent,改 SSOT 即全项目生效。
|
SSOT 固定在 `~/.pouch/skills/<name>/`。内容通过 **symlink** 分发到各 agent,改 SSOT 即全项目生效。
|
||||||
|
|
||||||
|
开始时解析当前 `SKILL.md` 所在目录,记为 `<pouch-skill-dir>`。
|
||||||
|
|
||||||
|
## 选择模式
|
||||||
|
|
||||||
|
- 创建、完善、转正草稿:执行「创建新的 skill」。
|
||||||
|
- 行为不对、触发不准、校验失败、可复用优化回流:执行「问题或优化回流」。
|
||||||
|
- 优化结构、减少 token 浪费、skill 太长、分层加载:执行「优化 skill 结构」。先读
|
||||||
|
[token-structure.md](references/token-structure.md)。
|
||||||
|
- 安装、卸载、浏览、init、status:执行「安装与维护」。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 在项目中使用 skill
|
## 在项目中使用 skill
|
||||||
@@ -38,7 +48,8 @@ pouch add <name> -a cursor -a claude -a codex -a agents -y
|
|||||||
pouch create <name> --idea "<用户原始想法>" --from-project .
|
pouch create <name> --idea "<用户原始想法>" --from-project .
|
||||||
```
|
```
|
||||||
|
|
||||||
4. 编辑 `~/.pouch/.drafts/<name>/SKILL.md`,完善触发条件、不适用场景、步骤、边界与验证方法。
|
4. 编辑 `~/.pouch/.drafts/<name>/SKILL.md`,按
|
||||||
|
[token-structure.md](references/token-structure.md) 写触发条件、步骤、边界与验证。
|
||||||
5. 编辑同目录的 `README.md`,用面向人类的语言说明用途、准备事项、可直接复制的请求示例、Agent 会做什么以及如何判断完成。README 不应复述 Agent 内部指令。
|
5. 编辑同目录的 `README.md`,用面向人类的语言说明用途、准备事项、可直接复制的请求示例、Agent 会做什么以及如何判断完成。README 不应复述 Agent 内部指令。
|
||||||
6. 仅在确有必要时增加 `references/`、`scripts/` 或 `assets/`。不要把项目专属路径、私有业务规则、一次性命令或密钥复制到通用 skill。
|
6. 仅在确有必要时增加 `references/`、`scripts/` 或 `assets/`。不要把项目专属路径、私有业务规则、一次性命令或密钥复制到通用 skill。
|
||||||
7. 运行校验并修复所有问题:
|
7. 运行校验并修复所有问题:
|
||||||
@@ -86,6 +97,24 @@ pouch publish skills/<name> -m "update <name>" --push
|
|||||||
|
|
||||||
软链正确时无需重新安装;SSOT 保存后项目立即读取新内容。
|
软链正确时无需重新安装;SSOT 保存后项目立即读取新内容。
|
||||||
|
|
||||||
|
## 优化 skill 结构
|
||||||
|
|
||||||
|
用户要求优化某个 skill 的结构、减少 token 浪费、skill 太长或分层加载时执行。只改
|
||||||
|
builtin 或草稿的 SSOT。第三方 catalog skill 不改安装目录,除非用户明确要维护 fork。
|
||||||
|
|
||||||
|
1. 确认目标名称。SSOT 为 `~/.pouch/skills/<name>/` 或 `~/.pouch/.drafts/<name>/`。
|
||||||
|
2. 读取 [token-structure.md](references/token-structure.md)。
|
||||||
|
3. 只读审计输出,不要把目标 skill 的 `references/` 全量读进上下文:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 <pouch-skill-dir>/scripts/audit_skill_structure.py <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 审计 `status: within-budget` 且无空泛引用、无条件批量加载警告:按该文件报告 Keep,不改文件。
|
||||||
|
5. 否则按该文件改 SSOT。一次只改点名的那一个 skill。
|
||||||
|
6. 运行 `pouch check <name>`,并按该文件做模式场景核对。
|
||||||
|
7. 再跑审计脚本,按该文件报告。不自动 commit 或 `pouch publish`。
|
||||||
|
|
||||||
## 安装与维护
|
## 安装与维护
|
||||||
|
|
||||||
安装本项目的 `pouch` skill 到所有 Agent:
|
安装本项目的 `pouch` skill 到所有 Agent:
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Skill 分层加载与结构预算
|
||||||
|
|
||||||
|
改结构是为了少灌上下文,不是删能力。功能回归不过就停,把刚搬出去的必做规则搬回 `SKILL.md`。
|
||||||
|
|
||||||
|
## 三层加载
|
||||||
|
|
||||||
|
| 层 | 内容 | 何时进入上下文 | 备注 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 1 | frontmatter `name` + `description` | 每个会话,所有已安装 skill | 只负责点名 |
|
||||||
|
| 2 | `SKILL.md` 正文 | skill 被点中 | 所有模式会一起进来 |
|
||||||
|
| 3 | `references/`、`scripts/`、`templates/` | 读到具体文件或执行脚本时 | 脚本应执行、不要 `cat` 源码 |
|
||||||
|
| — | `README.md` | 不应被 Agent 主动读取 | 给人看 |
|
||||||
|
|
||||||
|
文件拆了不等于省 token。`SKILL.md` 或 kickoff 写「每次都读 A、B、C」,等于把第 3 层又变成第 2 层。
|
||||||
|
|
||||||
|
## 预算
|
||||||
|
|
||||||
|
以本文件为准。`scripts/audit_skill_structure.py` 只测量,超标按这里判断。
|
||||||
|
|
||||||
|
| 对象 | 目标 | 硬顶 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `description` | 60–100 token;做什么、何时用、触发词 | 约 1024 字符(规范上限) |
|
||||||
|
| `SKILL.md` 正文 | 约 200 行 / 2500 token | 500 行 / 5000 token |
|
||||||
|
| 单份 reference | 按需加载;>100 行时文首加目录 | 只与 `SKILL.md` 相距一层 |
|
||||||
|
|
||||||
|
`description` 不要写流程。显式触发限制(例如「仅在用户调用 `/ack`」)值得保留,能避免误触发后灌入整包。漏触发比 description 多 40 token 更贵。
|
||||||
|
|
||||||
|
默认项目级安装;不要为省事对项目专用 skill 加 `-g`。安装范围只在用户明确要求优化安装时才动。
|
||||||
|
|
||||||
|
## `SKILL.md` 只留
|
||||||
|
|
||||||
|
- 模式路由:用户这句话走哪一模式。
|
||||||
|
- 所有模式都成立的 fail-closed。
|
||||||
|
- 带条件的指针:「若 X,读 `references/Y.md`」。禁止空的「见 `references/`」。
|
||||||
|
|
||||||
|
## 搬到 `references/` 或脚本
|
||||||
|
|
||||||
|
- 只在某一模式才走的步骤。
|
||||||
|
- 已在 `references/` 或脚本里的正文复述。
|
||||||
|
- 长命令、schema、示例:改成脚本输出或 `templates/`。
|
||||||
|
- 「每次都读」的清单:改成按任务条件加载。
|
||||||
|
|
||||||
|
引用只保持一层:`SKILL.md` → `references/foo.md`。不要 `SKILL.md` → A → B。同一事实只留一个家。
|
||||||
|
|
||||||
|
## 不要删
|
||||||
|
|
||||||
|
- 不可逆操作的 fail-closed(上传、部署、发版、关 worker)。
|
||||||
|
- 禁止把完整 yaml / 任务板 / 知识库灌进上下文的规则。
|
||||||
|
- 显式触发限制。
|
||||||
|
- 脚本已经 enforce 的规则:正文删复述,保留调用命令。
|
||||||
|
|
||||||
|
## 改造顺序
|
||||||
|
|
||||||
|
1. 去重:`SKILL.md` 复述某份 reference 或脚本契约 → 改成指针。
|
||||||
|
2. 按模式拆:初始化 / 检查 / 工作 / 其它模式的步骤离开正文。
|
||||||
|
3. 把无条件加载改成「若 X 则读 Y」。
|
||||||
|
4. 缩短 `description`。
|
||||||
|
5. 不主动改全局安装范围。
|
||||||
|
|
||||||
|
已在硬顶内、无复述、无无条件加载清单:报告 Keep,不改文件。一次只改用户点名的那一个 skill。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
1. `pouch check <name>` 通过。
|
||||||
|
2. 对每个模式写一句用户原话,核对该模式的必做步骤仍在「正文短清单」或「该模式明确要求读取的那一份 reference」里;fail-closed 仍在 `SKILL.md`。
|
||||||
|
3. 若某条规则被搬出去后,按那句原话走会漏读,把该规则搬回正文。
|
||||||
|
|
||||||
|
## 报告格式
|
||||||
|
|
||||||
|
```text
|
||||||
|
## pouch 结构优化:<name>:完成 | 无需改 | 阻塞
|
||||||
|
|
||||||
|
预算: SKILL.md 行数/token 前→后;description token 前→后
|
||||||
|
搬出: 模式或段落 → 目标文件
|
||||||
|
保留: 仍留在 SKILL.md 的 fail-closed / 触发限制
|
||||||
|
加载: 改掉的无条件读取清单(若有)
|
||||||
|
验证: pouch check;已核的模式场景
|
||||||
|
未做: 因会伤功能而没搬的内容
|
||||||
|
```
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Measure a skill's SKILL.md / description / references footprint.
|
||||||
|
|
||||||
|
Token counts are a CJK-aware heuristic, not a model tokenizer.
|
||||||
|
Budgets are defined in references/token-structure.md; this script only measures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SOFT_SKILL_LINES = 200
|
||||||
|
HARD_SKILL_LINES = 500
|
||||||
|
SOFT_SKILL_TOKENS = 2500
|
||||||
|
HARD_SKILL_TOKENS = 5000
|
||||||
|
SOFT_DESC_TOKENS = 120
|
||||||
|
HARD_DESC_CHARS = 1024
|
||||||
|
|
||||||
|
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
|
||||||
|
_LATIN_RE = re.compile(r"[A-Za-z0-9_]+")
|
||||||
|
_OTHER_RE = re.compile(r"[^\s\w\u4e00-\u9fff]")
|
||||||
|
_DESC_BLOCK_RE = re.compile(
|
||||||
|
r"^description:\s*(?:>-|>\||>|-)?\s*\n((?:[ \t].+\n?)+)",
|
||||||
|
re.MULTILINE,
|
||||||
|
)
|
||||||
|
_DESC_INLINE_RE = re.compile(r"^description:\s*(.+)$", re.MULTILINE)
|
||||||
|
_HEADING_RE = re.compile(r"^## .+$", re.MULTILINE)
|
||||||
|
_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
|
||||||
|
_VAGUE_REFS_RE = re.compile(
|
||||||
|
r"见\s*`?references/?`?|详见\s*`?references/?`?|see\s+references/?",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def approx_tokens(text: str) -> int:
|
||||||
|
cjk = len(_CJK_RE.findall(text))
|
||||||
|
latin = len(_LATIN_RE.findall(text))
|
||||||
|
other = len(_OTHER_RE.findall(text))
|
||||||
|
return int(cjk + latin * 1.3 + other * 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_frontmatter(text: str) -> tuple[str, str, str]:
|
||||||
|
if not text.startswith("---"):
|
||||||
|
return "", "", text
|
||||||
|
end = text.find("\n---", 3)
|
||||||
|
if end == -1:
|
||||||
|
return "", "", text
|
||||||
|
frontmatter = text[4:end]
|
||||||
|
body = text[end + 4 :]
|
||||||
|
name = ""
|
||||||
|
name_match = re.search(r"^name:\s*(.+)$", frontmatter, re.MULTILINE)
|
||||||
|
if name_match:
|
||||||
|
name = name_match.group(1).strip().strip("\"'")
|
||||||
|
desc = ""
|
||||||
|
block = _DESC_BLOCK_RE.search(frontmatter)
|
||||||
|
if block:
|
||||||
|
desc = " ".join(
|
||||||
|
line.strip() for line in block.group(1).splitlines() if line.strip()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
inline = _DESC_INLINE_RE.search(frontmatter)
|
||||||
|
if inline:
|
||||||
|
desc = inline.group(1).strip().strip("\"'")
|
||||||
|
return name, desc, body
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_skill_dir(spec: str) -> Path:
|
||||||
|
path = Path(spec).expanduser()
|
||||||
|
if (path / "SKILL.md").is_file():
|
||||||
|
return path.resolve()
|
||||||
|
if path.is_file() and path.name == "SKILL.md":
|
||||||
|
return path.parent.resolve()
|
||||||
|
|
||||||
|
home = Path.home() / ".pouch"
|
||||||
|
for candidate in (home / "skills" / spec, home / ".drafts" / spec):
|
||||||
|
if (candidate / "SKILL.md").is_file():
|
||||||
|
return candidate.resolve()
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"找不到 skill: {spec}(需要目录内有 SKILL.md,或 ~/.pouch/skills/<name>)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_reference_files(skill_dir: Path) -> list[Path]:
|
||||||
|
files: list[Path] = []
|
||||||
|
refs_dir = skill_dir / "references"
|
||||||
|
if refs_dir.is_dir():
|
||||||
|
files.extend(sorted(p for p in refs_dir.glob("*.md") if p.is_file()))
|
||||||
|
loose = skill_dir / "reference.md"
|
||||||
|
if loose.is_file():
|
||||||
|
files.append(loose)
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def relative_links(text: str) -> list[str]:
|
||||||
|
found: list[str] = []
|
||||||
|
for raw in _LINK_RE.findall(text):
|
||||||
|
target = raw.strip().split("#", 1)[0].split("?", 1)[0]
|
||||||
|
if not target or "://" in target or target.startswith(("mailto:", "/")):
|
||||||
|
continue
|
||||||
|
found.append(target)
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def section_sizes(body: str) -> list[tuple[str, int, int]]:
|
||||||
|
matches = list(_HEADING_RE.finditer(body))
|
||||||
|
rows: list[tuple[str, int, int]] = []
|
||||||
|
if not matches:
|
||||||
|
title = body.strip().splitlines()[0] if body.strip() else "(body)"
|
||||||
|
rows.append((title[:60], body.count("\n") + 1, approx_tokens(body)))
|
||||||
|
return rows
|
||||||
|
preamble = body[: matches[0].start()]
|
||||||
|
if preamble.strip():
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
"(preamble)",
|
||||||
|
preamble.count("\n") + 1,
|
||||||
|
approx_tokens(preamble),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for index, match in enumerate(matches):
|
||||||
|
start = match.start()
|
||||||
|
end = matches[index + 1].start() if index + 1 < len(matches) else len(body)
|
||||||
|
chunk = body[start:end]
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
match.group(0)[:60],
|
||||||
|
chunk.count("\n") + 1,
|
||||||
|
approx_tokens(chunk),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def bulk_load_sections(body: str, ref_names: set[str]) -> list[str]:
|
||||||
|
flagged: list[str] = []
|
||||||
|
matches = list(_HEADING_RE.finditer(body))
|
||||||
|
spans: list[tuple[str, str]] = []
|
||||||
|
if matches:
|
||||||
|
for index, match in enumerate(matches):
|
||||||
|
start = match.start()
|
||||||
|
end = matches[index + 1].start() if index + 1 < len(matches) else len(body)
|
||||||
|
spans.append((match.group(0), body[start:end]))
|
||||||
|
else:
|
||||||
|
spans.append(("(body)", body))
|
||||||
|
for heading, chunk in spans:
|
||||||
|
hits = {name for name in ref_names if name in chunk}
|
||||||
|
if len(hits) >= 3:
|
||||||
|
flagged.append(f"{heading} → {', '.join(sorted(hits))}")
|
||||||
|
return flagged
|
||||||
|
|
||||||
|
|
||||||
|
def audit(skill_dir: Path) -> tuple[str, int]:
|
||||||
|
skill_md = skill_dir / "SKILL.md"
|
||||||
|
text = skill_md.read_text(encoding="utf-8")
|
||||||
|
name, desc, body = parse_frontmatter(text)
|
||||||
|
lines = text.count("\n") + 1
|
||||||
|
skill_tokens = approx_tokens(text)
|
||||||
|
desc_tokens = approx_tokens(desc)
|
||||||
|
desc_chars = len(desc)
|
||||||
|
sections = section_sizes(body)
|
||||||
|
ref_files = iter_reference_files(skill_dir)
|
||||||
|
links = relative_links(text)
|
||||||
|
ref_names = {path.name for path in ref_files}
|
||||||
|
linked_refs = {
|
||||||
|
Path(target).name
|
||||||
|
for target in links
|
||||||
|
if Path(target).name in ref_names or target.startswith("references/")
|
||||||
|
}
|
||||||
|
orphans = sorted(ref_names - linked_refs)
|
||||||
|
vague = bool(_VAGUE_REFS_RE.search(text))
|
||||||
|
bulk = bulk_load_sections(body, ref_names)
|
||||||
|
|
||||||
|
warnings: list[str] = []
|
||||||
|
notes: list[str] = []
|
||||||
|
if lines > HARD_SKILL_LINES or skill_tokens > HARD_SKILL_TOKENS:
|
||||||
|
warnings.append(
|
||||||
|
f"HARD SKILL.md {lines} 行 / {skill_tokens} token "
|
||||||
|
f"(硬顶 {HARD_SKILL_LINES} 行 / {HARD_SKILL_TOKENS} token)"
|
||||||
|
)
|
||||||
|
elif lines > SOFT_SKILL_LINES or skill_tokens > SOFT_SKILL_TOKENS:
|
||||||
|
warnings.append(
|
||||||
|
f"SOFT SKILL.md {lines} 行 / {skill_tokens} token "
|
||||||
|
f"(目标 {SOFT_SKILL_LINES} 行 / {SOFT_SKILL_TOKENS} token)"
|
||||||
|
)
|
||||||
|
if desc_chars > HARD_DESC_CHARS:
|
||||||
|
warnings.append(
|
||||||
|
f"HARD description {desc_chars} 字符 (硬顶 {HARD_DESC_CHARS})"
|
||||||
|
)
|
||||||
|
elif desc_tokens > SOFT_DESC_TOKENS:
|
||||||
|
warnings.append(
|
||||||
|
f"SOFT description ~{desc_tokens} token (目标 ≤{SOFT_DESC_TOKENS})"
|
||||||
|
)
|
||||||
|
if vague:
|
||||||
|
warnings.append("SKILL.md 含空泛「见 references/」,应改成「若 X 则读 Y.md」")
|
||||||
|
for item in bulk:
|
||||||
|
warnings.append(f"无条件批量加载风险: {item}")
|
||||||
|
for orphan in orphans:
|
||||||
|
notes.append(f"reference 未被 SKILL.md 链接: {orphan}")
|
||||||
|
|
||||||
|
if any(item.startswith("HARD ") for item in warnings):
|
||||||
|
status = "over-hard-budget"
|
||||||
|
elif warnings:
|
||||||
|
status = "over-soft-budget"
|
||||||
|
else:
|
||||||
|
status = "within-budget"
|
||||||
|
|
||||||
|
out: list[str] = [
|
||||||
|
f"skill: {name or skill_dir.name}",
|
||||||
|
f"path: {skill_dir}",
|
||||||
|
f"status: {status}",
|
||||||
|
f"SKILL.md: {lines} lines, ~{skill_tokens} tokens",
|
||||||
|
f"description: {desc_chars} chars, ~{desc_tokens} tokens",
|
||||||
|
"sections:",
|
||||||
|
]
|
||||||
|
for heading, sec_lines, sec_tokens in sections:
|
||||||
|
out.append(f" {sec_tokens:5d} tok {sec_lines:4d} lines {heading}")
|
||||||
|
out.append("references:")
|
||||||
|
if not ref_files:
|
||||||
|
out.append(" (none)")
|
||||||
|
for path in ref_files:
|
||||||
|
ref_text = path.read_text(encoding="utf-8")
|
||||||
|
rel = path.relative_to(skill_dir)
|
||||||
|
out.append(
|
||||||
|
f" {approx_tokens(ref_text):5d} tok "
|
||||||
|
f"{ref_text.count(chr(10)) + 1:4d} lines {rel}"
|
||||||
|
)
|
||||||
|
out.append("warnings:")
|
||||||
|
if not warnings:
|
||||||
|
out.append(" (none)")
|
||||||
|
else:
|
||||||
|
out.extend(f" - {item}" for item in warnings)
|
||||||
|
out.append("notes:")
|
||||||
|
if not notes:
|
||||||
|
out.append(" (none)")
|
||||||
|
else:
|
||||||
|
out.extend(f" - {item}" for item in notes)
|
||||||
|
return "\n".join(out) + "\n", 0
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Audit a pouch skill's token/structure footprint."
|
||||||
|
)
|
||||||
|
parser.add_argument("skill", help="skill 名,或含 SKILL.md 的目录")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
try:
|
||||||
|
skill_dir = resolve_skill_dir(args.skill)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
print(exc, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
report, code = audit(skill_dir)
|
||||||
|
sys.stdout.write(report)
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SCRIPT = (
|
||||||
|
REPO_ROOT
|
||||||
|
/ "skills"
|
||||||
|
/ "pouch"
|
||||||
|
/ "scripts"
|
||||||
|
/ "audit_skill_structure.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditSkillStructureTests(unittest.TestCase):
|
||||||
|
def _write_skill(self, root: Path, *, body: str, description: str = "short") -> Path:
|
||||||
|
skill = root / "demo"
|
||||||
|
skill.mkdir()
|
||||||
|
(skill / "SKILL.md").write_text(
|
||||||
|
"---\n"
|
||||||
|
"name: demo\n"
|
||||||
|
f"description: {description}\n"
|
||||||
|
"---\n\n"
|
||||||
|
f"{body}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return skill
|
||||||
|
|
||||||
|
def _run(self, skill_dir: Path) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), str(skill_dir)],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reports_within_budget_and_reference_size(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
skill = self._write_skill(
|
||||||
|
Path(tmp),
|
||||||
|
body="# Demo\n\n## 步骤\n\n1. 做一件事。\n",
|
||||||
|
)
|
||||||
|
(skill / "references").mkdir()
|
||||||
|
(skill / "references" / "guide.md").write_text(
|
||||||
|
"# Guide\n\n细节。\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
result = self._run(skill)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("status: within-budget", result.stdout)
|
||||||
|
self.assertIn("## 步骤", result.stdout)
|
||||||
|
self.assertIn("references/guide.md", result.stdout)
|
||||||
|
self.assertIn("notes:", result.stdout)
|
||||||
|
self.assertIn("reference 未被 SKILL.md 链接: guide.md", result.stdout)
|
||||||
|
|
||||||
|
def test_flags_hard_budget_and_vague_references(self) -> None:
|
||||||
|
padding = "这是一段占位说明,用来把正文撑过硬顶。" * 400
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
skill = self._write_skill(
|
||||||
|
Path(tmp),
|
||||||
|
body="# Demo\n\n详见 references/。\n\n" + padding,
|
||||||
|
)
|
||||||
|
result = self._run(skill)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn("status: over-hard-budget", result.stdout)
|
||||||
|
self.assertIn("HARD SKILL.md", result.stdout)
|
||||||
|
self.assertIn("见 references/", result.stdout)
|
||||||
|
|
||||||
|
def test_missing_skill_exits_2(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
result = self._run(Path(tmp) / "missing")
|
||||||
|
self.assertEqual(result.returncode, 2)
|
||||||
|
self.assertIn("找不到 skill", result.stderr)
|
||||||
Reference in New Issue
Block a user