@softspark/ai-toolkit 4.20.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 +107 -0
- package/README.md +32 -22
- 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/documentation-standards/SKILL.md +25 -5
- 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 +59 -20
- 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 +84 -2
|
@@ -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
|
})
|
|
@@ -95,11 +96,31 @@ HOOK_REQUIRED_FIELDS = {
|
|
|
95
96
|
"mcp_tool": ("server", "tool", "arguments"),
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
# The taxonomy. `app/skills/documentation-standards/SKILL.md` documents the same
|
|
100
|
+
# eight and is what authors read; this set is what rejects a typo. They are one
|
|
101
|
+
# list in two places, so a change belongs in both.
|
|
102
|
+
#
|
|
103
|
+
# `decisions` and `runbooks` were missing until v4.21.0 while the kb-migration
|
|
104
|
+
# SOP had been telling people to create those directories for months, so a
|
|
105
|
+
# correctly-filed ADR failed validation.
|
|
98
106
|
VALID_KB_CATEGORIES = frozenset({
|
|
99
107
|
"reference", "howto", "procedures", "troubleshooting", "best-practices",
|
|
100
|
-
"planning",
|
|
108
|
+
"decisions", "runbooks", "planning",
|
|
101
109
|
})
|
|
102
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
|
+
|
|
103
124
|
VALID_RULE_CATEGORIES = frozenset({
|
|
104
125
|
"coding-style",
|
|
105
126
|
"testing",
|
|
@@ -219,6 +240,22 @@ def _body_line_count(filepath: Path) -> int:
|
|
|
219
240
|
return count
|
|
220
241
|
|
|
221
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
|
+
|
|
222
259
|
def _body_nonblank_count(filepath: Path) -> int:
|
|
223
260
|
"""Count non-blank lines after the second --- delimiter."""
|
|
224
261
|
count = 0
|
|
@@ -325,6 +362,18 @@ def _validate_skill_frontmatter(tk_dir: Path, skill_path: Path,
|
|
|
325
362
|
if body_lines > 500:
|
|
326
363
|
vr.warn(f"skills/{name}/SKILL.md: body is {body_lines} lines (recommended < 500)")
|
|
327
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
|
+
|
|
328
377
|
desc_value = _fm_field(fm_lines, "description")
|
|
329
378
|
if len(desc_value) > 1024:
|
|
330
379
|
vr.warn(f"{name} - Description exceeds 1024 characters")
|
|
@@ -377,6 +426,7 @@ def validate_skills(tk_dir: Path, vr: ValidationResult) -> int:
|
|
|
377
426
|
print("## Skills")
|
|
378
427
|
skills_dir = tk_dir / "app" / "skills"
|
|
379
428
|
skill_count = 0
|
|
429
|
+
largest_body = (0, "")
|
|
380
430
|
|
|
381
431
|
if not skills_dir.is_dir():
|
|
382
432
|
vr.error("app/skills directory not found")
|
|
@@ -401,8 +451,15 @@ def validate_skills(tk_dir: Path, vr: ValidationResult) -> int:
|
|
|
401
451
|
fm_lines = _parse_frontmatter_lines(skill_file)
|
|
402
452
|
_validate_skill_frontmatter(tk_dir, skill_path, fm_lines, vr)
|
|
403
453
|
_validate_skill_references(tk_dir, skill_path, fm_lines, vr)
|
|
454
|
+
largest_body = max(largest_body, (_body_bytes(skill_file), name))
|
|
404
455
|
|
|
405
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
|
+
)
|
|
406
463
|
print()
|
|
407
464
|
return skill_count
|
|
408
465
|
|
|
@@ -730,6 +787,31 @@ def validate_kb_documents(tk_dir: Path, vr: ValidationResult) -> None:
|
|
|
730
787
|
vr.error(f"{kb_name} - Invalid category '{kb_category}' (valid: {', '.join(sorted(VALID_KB_CATEGORIES))})")
|
|
731
788
|
kb_errors += 1
|
|
732
789
|
|
|
790
|
+
# `section` is a legacy alias for `category`, not a second axis. A
|
|
791
|
+
# document carrying both with different values is indexed twice and
|
|
792
|
+
# found once, and nothing about that is visible to its author.
|
|
793
|
+
kb_section = _fm_field(fm_lines, "section").strip()
|
|
794
|
+
if kb_section and kb_category and kb_section != kb_category:
|
|
795
|
+
vr.error(
|
|
796
|
+
f"{kb_name} - section '{kb_section}' disagrees with category "
|
|
797
|
+
f"'{kb_category}'; section is an alias and must match"
|
|
798
|
+
)
|
|
799
|
+
kb_errors += 1
|
|
800
|
+
|
|
801
|
+
# A document filed under a taxonomy directory must declare that
|
|
802
|
+
# category, or a browsing reader and a search hit disagree about what it
|
|
803
|
+
# is. The rule is scoped to directories that ARE category names on
|
|
804
|
+
# purpose: `kb/history/completed/` is a lifecycle location, not a type,
|
|
805
|
+
# and a finished plan is still a `planning` document.
|
|
806
|
+
relative = kb_file.relative_to(kb_dir).parts
|
|
807
|
+
top = relative[0] if len(relative) > 1 else ""
|
|
808
|
+
if kb_category and top in VALID_KB_CATEGORIES and top != kb_category:
|
|
809
|
+
vr.error(
|
|
810
|
+
f"{kb_name} - is in {top}/ but declares category "
|
|
811
|
+
f"'{kb_category}'; they must agree"
|
|
812
|
+
)
|
|
813
|
+
kb_errors += 1
|
|
814
|
+
|
|
733
815
|
# Validate tags is not empty
|
|
734
816
|
tags_val = _fm_field(fm_lines, "tags")
|
|
735
817
|
if tags_val:
|