@softspark/ai-toolkit 4.21.0 → 4.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/README.md +32 -28
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/agents/code-reviewer.md +32 -15
- package/app/skills/a11y-validate/SKILL.md +61 -179
- package/app/skills/a11y-validate/reference/scanner-categories.md +174 -0
- package/app/skills/brainstorm/SKILL.md +174 -0
- package/app/skills/ci/SKILL.md +1 -0
- package/app/skills/debug/SKILL.md +2 -1
- package/app/skills/fix/SKILL.md +1 -1
- package/app/skills/hipaa-validate/SKILL.md +25 -221
- package/app/skills/hipaa-validate/reference/scanner-categories.md +224 -0
- package/app/skills/pr/SKILL.md +1 -1
- package/app/skills/review/SKILL.md +49 -4
- package/app/skills/seo-validate/SKILL.md +63 -309
- package/app/skills/seo-validate/reference/scanner-categories.md +304 -0
- package/app/surface.json +296 -0
- package/benchmarks/ecosystem-doctor-snapshot.json +15 -19
- package/kb/procedures/release-preparation-sop.md +44 -2
- package/kb/reference/architecture-overview.md +3 -3
- package/kb/reference/skills-catalog.md +10 -14
- package/llms-full.txt +58 -19
- package/manifest.json +3 -3
- package/package.json +6 -3
- package/scripts/check_split.py +493 -0
- package/scripts/surface_manifest.py +246 -0
- package/scripts/sync_badges.py +133 -0
- package/scripts/validate.py +51 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
# Copyright 2024-2026 Lukasz Krzemien (biuro@softspark.eu)
|
|
4
|
+
# Source: https://github.com/softspark/ai-toolkit
|
|
5
|
+
|
|
6
|
+
"""Public surface manifest — the enforceable half of BACKWARD_COMPATIBILITY.md.
|
|
7
|
+
|
|
8
|
+
`BACKWARD_COMPATIBILITY.md` names the surfaces users depend on. Prose stops
|
|
9
|
+
nothing: rename a skill and every gate stays green. This turns the list into a
|
|
10
|
+
committed snapshot and a test.
|
|
11
|
+
|
|
12
|
+
The check is deliberately one-directional:
|
|
13
|
+
|
|
14
|
+
entry in the manifest, missing from the tree -> FAIL (a removal)
|
|
15
|
+
entry in the tree, missing from the manifest -> pass (an addition)
|
|
16
|
+
|
|
17
|
+
Additions are free because a surface nobody has installed yet has no users to
|
|
18
|
+
break. Removals fail because someone out there pinned the version that had it.
|
|
19
|
+
|
|
20
|
+
The manifest is NOT regenerated automatically. If it were, deleting a skill would
|
|
21
|
+
delete its manifest entry in the same breath and the check would detect nothing.
|
|
22
|
+
`--update` is a deliberate act: run it, read the diff, and any line that
|
|
23
|
+
disappeared is a breaking change owing an entry in DECISIONS.md.
|
|
24
|
+
|
|
25
|
+
Stdlib-only.
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
python3 scripts/surface_manifest.py # check, human-readable
|
|
29
|
+
python3 scripts/surface_manifest.py --json # check, machine-readable
|
|
30
|
+
python3 scripts/surface_manifest.py --update # rewrite app/surface.json
|
|
31
|
+
python3 scripts/surface_manifest.py --toolkit-dir /path
|
|
32
|
+
|
|
33
|
+
Exit codes:
|
|
34
|
+
0 no protected surface was removed
|
|
35
|
+
1 a removal detected (or the manifest is missing on a check run)
|
|
36
|
+
2 usage error
|
|
37
|
+
"""
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import json
|
|
41
|
+
import re
|
|
42
|
+
import sys
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
46
|
+
from _common import toolkit_dir as default_toolkit_dir
|
|
47
|
+
|
|
48
|
+
MANIFEST_RELPATH = Path("app") / "surface.json"
|
|
49
|
+
|
|
50
|
+
FM_FIELD_RE = re.compile(r"^([a-z][a-z0-9-]*):")
|
|
51
|
+
CLI_COMMAND_RE = re.compile(r"^\s+'?([a-z][a-z0-9-]*)'?\s*:")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _frontmatter_fields(path: Path) -> set[str]:
|
|
55
|
+
parts = path.read_text(encoding="utf-8").split("---")
|
|
56
|
+
if len(parts) < 3:
|
|
57
|
+
return set()
|
|
58
|
+
return {
|
|
59
|
+
m.group(1)
|
|
60
|
+
for line in parts[1].splitlines()
|
|
61
|
+
if (m := FM_FIELD_RE.match(line))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def collect_surface(tk_dir: Path) -> dict:
|
|
66
|
+
"""Read the current public surface out of the tree."""
|
|
67
|
+
skills_dir = tk_dir / "app" / "skills"
|
|
68
|
+
agents_dir = tk_dir / "app" / "agents"
|
|
69
|
+
|
|
70
|
+
skills: list[str] = []
|
|
71
|
+
skill_fields: set[str] = set()
|
|
72
|
+
for d in sorted(skills_dir.iterdir()) if skills_dir.is_dir() else []:
|
|
73
|
+
if not d.is_dir() or d.name.startswith("_") or not (d / "SKILL.md").is_file():
|
|
74
|
+
continue
|
|
75
|
+
skills.append(d.name)
|
|
76
|
+
skill_fields |= _frontmatter_fields(d / "SKILL.md")
|
|
77
|
+
|
|
78
|
+
agents: list[str] = []
|
|
79
|
+
agent_fields: set[str] = set()
|
|
80
|
+
for f in sorted(agents_dir.glob("*.md")) if agents_dir.is_dir() else []:
|
|
81
|
+
agents.append(f.stem)
|
|
82
|
+
agent_fields |= _frontmatter_fields(f)
|
|
83
|
+
|
|
84
|
+
hooks_dir = tk_dir / "app" / "hooks"
|
|
85
|
+
hook_scripts = sorted(
|
|
86
|
+
f.name for f in hooks_dir.glob("*.sh") if not f.name.startswith("_")
|
|
87
|
+
) if hooks_dir.is_dir() else []
|
|
88
|
+
|
|
89
|
+
hook_events: list[str] = []
|
|
90
|
+
hooks_json = tk_dir / "app" / "hooks.json"
|
|
91
|
+
if hooks_json.is_file():
|
|
92
|
+
try:
|
|
93
|
+
hook_events = sorted(json.loads(hooks_json.read_text(encoding="utf-8")).get("hooks", {}))
|
|
94
|
+
except (json.JSONDecodeError, OSError):
|
|
95
|
+
hook_events = []
|
|
96
|
+
|
|
97
|
+
plugins_dir = tk_dir / "app" / "plugins"
|
|
98
|
+
packs = sorted(
|
|
99
|
+
d.name for d in plugins_dir.iterdir()
|
|
100
|
+
if d.is_dir() and not d.name.startswith(".")
|
|
101
|
+
) if plugins_dir.is_dir() else []
|
|
102
|
+
|
|
103
|
+
kb_categories: list[str] = []
|
|
104
|
+
validate_py = tk_dir / "scripts" / "validate.py"
|
|
105
|
+
if validate_py.is_file():
|
|
106
|
+
text = validate_py.read_text(encoding="utf-8")
|
|
107
|
+
block = re.search(r"VALID_KB_CATEGORIES\s*=\s*frozenset\(\{(.*?)\}\)", text, re.S)
|
|
108
|
+
if block:
|
|
109
|
+
kb_categories = sorted(re.findall(r'"([a-z-]+)"', block.group(1)))
|
|
110
|
+
|
|
111
|
+
cli_commands: list[str] = []
|
|
112
|
+
cli = tk_dir / "bin" / "ai-toolkit.js"
|
|
113
|
+
if not cli.is_file():
|
|
114
|
+
candidates = sorted((tk_dir / "bin").glob("*.js")) if (tk_dir / "bin").is_dir() else []
|
|
115
|
+
cli = candidates[0] if candidates else cli
|
|
116
|
+
if cli.is_file():
|
|
117
|
+
# The COMMANDS map is what `--help` prints, so it is the published surface.
|
|
118
|
+
# An undocumented internal branch is not a promise and is not captured here.
|
|
119
|
+
block = re.search(r"const COMMANDS\s*=\s*\{(.*?)\n\};", cli.read_text(encoding="utf-8"), re.S)
|
|
120
|
+
if block:
|
|
121
|
+
cli_commands = sorted({
|
|
122
|
+
m.group(1) for line in block.group(1).splitlines()
|
|
123
|
+
if (m := CLI_COMMAND_RE.match(line))
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"skills": skills,
|
|
128
|
+
"agents": agents,
|
|
129
|
+
"skill_frontmatter_fields": sorted(skill_fields),
|
|
130
|
+
"agent_frontmatter_fields": sorted(agent_fields),
|
|
131
|
+
"hook_scripts": hook_scripts,
|
|
132
|
+
"hook_events": hook_events,
|
|
133
|
+
"plugin_packs": packs,
|
|
134
|
+
"kb_categories": kb_categories,
|
|
135
|
+
"cli_commands": cli_commands,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def compare(manifest: dict, current: dict) -> dict:
|
|
140
|
+
"""Removals fail, additions pass. See the module docstring for why."""
|
|
141
|
+
removed: dict[str, list[str]] = {}
|
|
142
|
+
added: dict[str, list[str]] = {}
|
|
143
|
+
for key, entries in manifest.items():
|
|
144
|
+
if key.startswith("_"):
|
|
145
|
+
continue
|
|
146
|
+
have = set(current.get(key, []))
|
|
147
|
+
gone = [e for e in entries if e not in have]
|
|
148
|
+
if gone:
|
|
149
|
+
removed[key] = gone
|
|
150
|
+
new = [e for e in current.get(key, []) if e not in set(entries)]
|
|
151
|
+
if new:
|
|
152
|
+
added[key] = new
|
|
153
|
+
return {"removed": removed, "added": added, "ok": not removed}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _write_manifest(path: Path, current: dict) -> None:
|
|
157
|
+
payload = {
|
|
158
|
+
"_comment": (
|
|
159
|
+
"Public surface snapshot. Removing an entry is a breaking change: see "
|
|
160
|
+
"BACKWARD_COMPATIBILITY.md, and record it in DECISIONS.md. Regenerate "
|
|
161
|
+
"deliberately with `python3 scripts/surface_manifest.py --update` and "
|
|
162
|
+
"read the diff — never as a reflex to green a red build."
|
|
163
|
+
),
|
|
164
|
+
**current,
|
|
165
|
+
}
|
|
166
|
+
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main(argv: list[str]) -> int:
|
|
170
|
+
update = False
|
|
171
|
+
as_json = False
|
|
172
|
+
tk_dir = default_toolkit_dir
|
|
173
|
+
|
|
174
|
+
idx = 0
|
|
175
|
+
while idx < len(argv):
|
|
176
|
+
arg = argv[idx]
|
|
177
|
+
if arg == "--update":
|
|
178
|
+
update = True
|
|
179
|
+
elif arg == "--json":
|
|
180
|
+
as_json = True
|
|
181
|
+
elif arg in ("-h", "--help"):
|
|
182
|
+
print(__doc__)
|
|
183
|
+
return 0
|
|
184
|
+
elif arg == "--toolkit-dir":
|
|
185
|
+
idx += 1
|
|
186
|
+
if idx >= len(argv):
|
|
187
|
+
print("ERROR: --toolkit-dir requires a value", file=sys.stderr)
|
|
188
|
+
return 2
|
|
189
|
+
tk_dir = Path(argv[idx])
|
|
190
|
+
elif arg.startswith("-"):
|
|
191
|
+
print(f"ERROR: unknown option: {arg}", file=sys.stderr)
|
|
192
|
+
return 2
|
|
193
|
+
else:
|
|
194
|
+
tk_dir = Path(arg)
|
|
195
|
+
idx += 1
|
|
196
|
+
|
|
197
|
+
tk_dir = tk_dir.resolve()
|
|
198
|
+
manifest_path = tk_dir / MANIFEST_RELPATH
|
|
199
|
+
current = collect_surface(tk_dir)
|
|
200
|
+
|
|
201
|
+
if update:
|
|
202
|
+
_write_manifest(manifest_path, current)
|
|
203
|
+
total = sum(len(v) for v in current.values())
|
|
204
|
+
print(f"Wrote {manifest_path.relative_to(tk_dir)} — {total} protected entries")
|
|
205
|
+
print("Read the diff. Every line removed is a breaking change.")
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
if not manifest_path.is_file():
|
|
209
|
+
print(f"ERROR: {MANIFEST_RELPATH} not found — run with --update to create it",
|
|
210
|
+
file=sys.stderr)
|
|
211
|
+
return 1
|
|
212
|
+
|
|
213
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
214
|
+
result = compare(manifest, current)
|
|
215
|
+
|
|
216
|
+
if as_json:
|
|
217
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
218
|
+
return 0 if result["ok"] else 1
|
|
219
|
+
|
|
220
|
+
print("## Public Surface")
|
|
221
|
+
if result["removed"]:
|
|
222
|
+
for key, entries in sorted(result["removed"].items()):
|
|
223
|
+
for entry in entries:
|
|
224
|
+
print(f" ERROR: {key}: '{entry}' was removed from the public surface")
|
|
225
|
+
print()
|
|
226
|
+
print("A removal is a breaking change. Either restore it, or take the")
|
|
227
|
+
print("deprecation path in BACKWARD_COMPATIBILITY.md, record it in")
|
|
228
|
+
print("DECISIONS.md, and re-run with --update.")
|
|
229
|
+
print()
|
|
230
|
+
print("SURFACE CHECK FAILED")
|
|
231
|
+
return 1
|
|
232
|
+
|
|
233
|
+
protected = sum(len(v) for k, v in manifest.items() if not k.startswith("_"))
|
|
234
|
+
print(f" OK: {protected} protected entries intact")
|
|
235
|
+
if result["added"]:
|
|
236
|
+
new_total = sum(len(v) for v in result["added"].values())
|
|
237
|
+
detail = ", ".join(f"{k} +{len(v)}" for k, v in sorted(result["added"].items()))
|
|
238
|
+
print(f" Note: {new_total} new entries not yet protected ({detail})")
|
|
239
|
+
print(" Run --update before the next release to adopt them.")
|
|
240
|
+
print()
|
|
241
|
+
print("SURFACE CHECK PASSED")
|
|
242
|
+
return 0
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
# Copyright 2024-2026 Lukasz Krzemien (biuro@softspark.eu)
|
|
4
|
+
# Source: https://github.com/softspark/ai-toolkit
|
|
5
|
+
|
|
6
|
+
"""Sync the README count badges to ground truth.
|
|
7
|
+
|
|
8
|
+
`validate.py` errors when a README badge disagrees with the tree. The badges were
|
|
9
|
+
maintained by hand, so every change that added a test or a skill turned the build
|
|
10
|
+
red until someone edited a number — a tax paid per change, with a diagnosis cycle
|
|
11
|
+
each time it was forgotten.
|
|
12
|
+
|
|
13
|
+
This derives the three counts the same way `validate.py` does and rewrites them.
|
|
14
|
+
It runs inside `npm run generate:all`, which `prepublishOnly` executes before
|
|
15
|
+
`validate.py --strict`, so the badges are correct by the time anything checks them.
|
|
16
|
+
|
|
17
|
+
Counting rules, matched to validate.py so the two cannot disagree:
|
|
18
|
+
agents — *.md under app/agents/
|
|
19
|
+
skills — directories under app/skills/ with a SKILL.md, excluding _-prefixed
|
|
20
|
+
tests — `@test ` declarations across tests/*.bats
|
|
21
|
+
|
|
22
|
+
Stdlib-only.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
python3 scripts/sync_badges.py # rewrite README.md badges
|
|
26
|
+
python3 scripts/sync_badges.py --check # report drift, change nothing
|
|
27
|
+
python3 scripts/sync_badges.py --toolkit-dir /path
|
|
28
|
+
|
|
29
|
+
Exit codes:
|
|
30
|
+
0 badges are correct (or were rewritten)
|
|
31
|
+
1 --check found drift
|
|
32
|
+
2 usage error
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import re
|
|
37
|
+
import sys
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
41
|
+
from _common import toolkit_dir as default_toolkit_dir
|
|
42
|
+
|
|
43
|
+
TEST_RE = re.compile(r"^@test ", re.MULTILINE)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def counts(tk_dir: Path) -> dict[str, int]:
|
|
47
|
+
agents_dir = tk_dir / "app" / "agents"
|
|
48
|
+
skills_dir = tk_dir / "app" / "skills"
|
|
49
|
+
tests_dir = tk_dir / "tests"
|
|
50
|
+
|
|
51
|
+
agents = len(list(agents_dir.glob("*.md"))) if agents_dir.is_dir() else 0
|
|
52
|
+
skills = sum(
|
|
53
|
+
1 for d in skills_dir.iterdir()
|
|
54
|
+
if d.is_dir() and not d.name.startswith("_") and (d / "SKILL.md").is_file()
|
|
55
|
+
) if skills_dir.is_dir() else 0
|
|
56
|
+
tests = sum(
|
|
57
|
+
len(TEST_RE.findall(f.read_text(encoding="utf-8")))
|
|
58
|
+
for f in tests_dir.glob("*.bats")
|
|
59
|
+
) if tests_dir.is_dir() else 0
|
|
60
|
+
|
|
61
|
+
return {"agents": agents, "skills": skills, "tests": tests}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def sync(tk_dir: Path, check_only: bool) -> int:
|
|
65
|
+
readme = tk_dir / "README.md"
|
|
66
|
+
if not readme.is_file():
|
|
67
|
+
print("ERROR: README.md not found", file=sys.stderr)
|
|
68
|
+
return 2
|
|
69
|
+
|
|
70
|
+
text = readme.read_text(encoding="utf-8")
|
|
71
|
+
actual = counts(tk_dir)
|
|
72
|
+
drift: list[str] = []
|
|
73
|
+
updated = text
|
|
74
|
+
|
|
75
|
+
for label, value in actual.items():
|
|
76
|
+
pattern = re.compile(rf"({label}-)(\d+)")
|
|
77
|
+
match = pattern.search(updated)
|
|
78
|
+
if not match:
|
|
79
|
+
continue
|
|
80
|
+
if match.group(2) != str(value):
|
|
81
|
+
drift.append(f"{label}: badge {match.group(2)} != actual {value}")
|
|
82
|
+
updated = pattern.sub(rf"\g<1>{value}", updated, count=1)
|
|
83
|
+
|
|
84
|
+
if check_only:
|
|
85
|
+
for line in drift:
|
|
86
|
+
print(f" DRIFT: {line}")
|
|
87
|
+
if drift:
|
|
88
|
+
print("BADGE CHECK FAILED — run `python3 scripts/sync_badges.py`")
|
|
89
|
+
return 1
|
|
90
|
+
print(f" OK: badges match ({actual['agents']} agents, "
|
|
91
|
+
f"{actual['skills']} skills, {actual['tests']} tests)")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
if drift:
|
|
95
|
+
readme.write_text(updated, encoding="utf-8")
|
|
96
|
+
for line in drift:
|
|
97
|
+
print(f" synced {line}")
|
|
98
|
+
else:
|
|
99
|
+
print(f" OK: badges already match ({actual['agents']} agents, "
|
|
100
|
+
f"{actual['skills']} skills, {actual['tests']} tests)")
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main(argv: list[str]) -> int:
|
|
105
|
+
check_only = False
|
|
106
|
+
tk_dir = default_toolkit_dir
|
|
107
|
+
|
|
108
|
+
idx = 0
|
|
109
|
+
while idx < len(argv):
|
|
110
|
+
arg = argv[idx]
|
|
111
|
+
if arg == "--check":
|
|
112
|
+
check_only = True
|
|
113
|
+
elif arg in ("-h", "--help"):
|
|
114
|
+
print(__doc__)
|
|
115
|
+
return 0
|
|
116
|
+
elif arg == "--toolkit-dir":
|
|
117
|
+
idx += 1
|
|
118
|
+
if idx >= len(argv):
|
|
119
|
+
print("ERROR: --toolkit-dir requires a value", file=sys.stderr)
|
|
120
|
+
return 2
|
|
121
|
+
tk_dir = Path(argv[idx])
|
|
122
|
+
elif arg.startswith("-"):
|
|
123
|
+
print(f"ERROR: unknown option: {arg}", file=sys.stderr)
|
|
124
|
+
return 2
|
|
125
|
+
else:
|
|
126
|
+
tk_dir = Path(arg)
|
|
127
|
+
idx += 1
|
|
128
|
+
|
|
129
|
+
return sync(tk_dir.resolve(), check_only)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
sys.exit(main(sys.argv[1:]))
|
package/scripts/validate.py
CHANGED
|
@@ -45,6 +45,7 @@ VALID_TOOLS = frozenset({
|
|
|
45
45
|
VALID_HOOK_EVENTS = frozenset({
|
|
46
46
|
# Core lifecycle
|
|
47
47
|
"SessionStart", "SessionEnd", "UserPromptSubmit", "Notification",
|
|
48
|
+
"MessageDisplay",
|
|
48
49
|
# Tool lifecycle
|
|
49
50
|
"PreToolUse", "PostToolUse", "PostToolUseFailure", "PostToolBatch",
|
|
50
51
|
# Turn lifecycle
|
|
@@ -60,7 +61,7 @@ VALID_HOOK_EVENTS = frozenset({
|
|
|
60
61
|
"TaskCreated", "TaskCompleted", "TeammateIdle",
|
|
61
62
|
# Worktrees & environment
|
|
62
63
|
"WorktreeCreate", "WorktreeRemove",
|
|
63
|
-
"CwdChanged", "FileChanged", "ConfigChange",
|
|
64
|
+
"CwdChanged", "FileChanged", "ConfigChange", "DirectoryAdded",
|
|
64
65
|
# Setup / bootstrap
|
|
65
66
|
"Setup", "InstructionsLoaded",
|
|
66
67
|
})
|
|
@@ -107,6 +108,19 @@ VALID_KB_CATEGORIES = frozenset({
|
|
|
107
108
|
"decisions", "runbooks", "planning",
|
|
108
109
|
})
|
|
109
110
|
|
|
111
|
+
# Skill body budget, in bytes after the frontmatter block.
|
|
112
|
+
#
|
|
113
|
+
# The body loads in full every time a trigger matches, including when it matches
|
|
114
|
+
# by accident. Detail that only some runs need belongs in the skill's reference/
|
|
115
|
+
# directory, reached from a pointer in the body.
|
|
116
|
+
#
|
|
117
|
+
# Ratchet: lower WARN by 2_000 each release until it reaches 12_000. Never lower a
|
|
118
|
+
# threshold in the same change that something violates it — split the skill first,
|
|
119
|
+
# then tighten. The step lives in kb/procedures/release-preparation-sop.md so it
|
|
120
|
+
# does not rot as a comment nobody reads.
|
|
121
|
+
SKILL_BODY_BUDGET_ERROR = 20_000
|
|
122
|
+
SKILL_BODY_BUDGET_WARN = 18_000
|
|
123
|
+
|
|
110
124
|
VALID_RULE_CATEGORIES = frozenset({
|
|
111
125
|
"coding-style",
|
|
112
126
|
"testing",
|
|
@@ -226,6 +240,22 @@ def _body_line_count(filepath: Path) -> int:
|
|
|
226
240
|
return count
|
|
227
241
|
|
|
228
242
|
|
|
243
|
+
def _body_bytes(filepath: Path) -> int:
|
|
244
|
+
"""Byte size of the body — everything after the frontmatter block.
|
|
245
|
+
|
|
246
|
+
Unlike _body_line_count this anchors on the frontmatter delimiters at the top
|
|
247
|
+
of the file, so a `---` horizontal rule inside the body does not shift the
|
|
248
|
+
measurement.
|
|
249
|
+
"""
|
|
250
|
+
text = filepath.read_text(encoding="utf-8")
|
|
251
|
+
lines = text.splitlines(keepends=True)
|
|
252
|
+
if lines and lines[0].strip() == "---":
|
|
253
|
+
for idx in range(1, len(lines)):
|
|
254
|
+
if lines[idx].strip() == "---":
|
|
255
|
+
return len("".join(lines[idx + 1:]).encode("utf-8"))
|
|
256
|
+
return len(text.encode("utf-8"))
|
|
257
|
+
|
|
258
|
+
|
|
229
259
|
def _body_nonblank_count(filepath: Path) -> int:
|
|
230
260
|
"""Count non-blank lines after the second --- delimiter."""
|
|
231
261
|
count = 0
|
|
@@ -332,6 +362,18 @@ def _validate_skill_frontmatter(tk_dir: Path, skill_path: Path,
|
|
|
332
362
|
if body_lines > 500:
|
|
333
363
|
vr.warn(f"skills/{name}/SKILL.md: body is {body_lines} lines (recommended < 500)")
|
|
334
364
|
|
|
365
|
+
body_bytes = _body_bytes(skill_file)
|
|
366
|
+
if body_bytes > SKILL_BODY_BUDGET_ERROR:
|
|
367
|
+
vr.error(
|
|
368
|
+
f"skills/{name}/SKILL.md: body is {body_bytes} bytes "
|
|
369
|
+
f"(limit {SKILL_BODY_BUDGET_ERROR}) - move detail into reference/"
|
|
370
|
+
)
|
|
371
|
+
elif body_bytes > SKILL_BODY_BUDGET_WARN:
|
|
372
|
+
vr.warn(
|
|
373
|
+
f"skills/{name}/SKILL.md: body is {body_bytes} bytes "
|
|
374
|
+
f"(budget {SKILL_BODY_BUDGET_WARN}) - move detail into reference/"
|
|
375
|
+
)
|
|
376
|
+
|
|
335
377
|
desc_value = _fm_field(fm_lines, "description")
|
|
336
378
|
if len(desc_value) > 1024:
|
|
337
379
|
vr.warn(f"{name} - Description exceeds 1024 characters")
|
|
@@ -384,6 +426,7 @@ def validate_skills(tk_dir: Path, vr: ValidationResult) -> int:
|
|
|
384
426
|
print("## Skills")
|
|
385
427
|
skills_dir = tk_dir / "app" / "skills"
|
|
386
428
|
skill_count = 0
|
|
429
|
+
largest_body = (0, "")
|
|
387
430
|
|
|
388
431
|
if not skills_dir.is_dir():
|
|
389
432
|
vr.error("app/skills directory not found")
|
|
@@ -408,8 +451,15 @@ def validate_skills(tk_dir: Path, vr: ValidationResult) -> int:
|
|
|
408
451
|
fm_lines = _parse_frontmatter_lines(skill_file)
|
|
409
452
|
_validate_skill_frontmatter(tk_dir, skill_path, fm_lines, vr)
|
|
410
453
|
_validate_skill_references(tk_dir, skill_path, fm_lines, vr)
|
|
454
|
+
largest_body = max(largest_body, (_body_bytes(skill_file), name))
|
|
411
455
|
|
|
412
456
|
print(f" Found: {skill_count} skills")
|
|
457
|
+
if skill_count:
|
|
458
|
+
size, worst = largest_body
|
|
459
|
+
print(
|
|
460
|
+
f" Body budget: largest is {worst} at {size} bytes "
|
|
461
|
+
f"(warn {SKILL_BODY_BUDGET_WARN}, error {SKILL_BODY_BUDGET_ERROR})"
|
|
462
|
+
)
|
|
413
463
|
print()
|
|
414
464
|
return skill_count
|
|
415
465
|
|