@softspark/ai-toolkit 4.21.0 → 4.22.1

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/README.md +40 -28
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/agents/code-reviewer.md +32 -15
  5. package/app/skills/a11y-validate/SKILL.md +61 -179
  6. package/app/skills/a11y-validate/reference/scanner-categories.md +174 -0
  7. package/app/skills/brainstorm/SKILL.md +174 -0
  8. package/app/skills/brand-voice/SKILL.md +1 -1
  9. package/app/skills/ci/SKILL.md +2 -1
  10. package/app/skills/debug/SKILL.md +3 -2
  11. package/app/skills/deploy/SKILL.md +1 -1
  12. package/app/skills/explore/SKILL.md +1 -1
  13. package/app/skills/explore/scripts/visualize.py +15 -1
  14. package/app/skills/fix/SKILL.md +3 -3
  15. package/app/skills/hipaa-validate/SKILL.md +25 -221
  16. package/app/skills/hipaa-validate/reference/scanner-categories.md +224 -0
  17. package/app/skills/migrate/SKILL.md +1 -1
  18. package/app/skills/pr/SKILL.md +2 -2
  19. package/app/skills/review/SKILL.md +50 -5
  20. package/app/skills/rollback/SKILL.md +1 -1
  21. package/app/skills/seo-validate/SKILL.md +63 -309
  22. package/app/skills/seo-validate/reference/scanner-categories.md +304 -0
  23. package/app/surface.json +296 -0
  24. package/benchmarks/ecosystem-doctor-snapshot.json +15 -19
  25. package/kb/procedures/post-release-testing-sop.md +92 -3
  26. package/kb/procedures/release-preparation-sop.md +44 -2
  27. package/kb/reference/architecture-overview.md +3 -3
  28. package/kb/reference/skills-catalog.md +10 -14
  29. package/llms-full.txt +150 -22
  30. package/manifest.json +3 -3
  31. package/package.json +6 -3
  32. package/scripts/check_split.py +493 -0
  33. package/scripts/surface_manifest.py +246 -0
  34. package/scripts/sync_badges.py +133 -0
  35. package/scripts/validate.py +118 -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:]))
@@ -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,11 +362,88 @@ 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")
338
380
 
339
381
 
382
+ def _validate_skill_script_invocations(skill_path: Path, vr: ValidationResult) -> None:
383
+ """Every documented run of a skill-owned script must resolve after install.
384
+
385
+ A skill's own scripts live beside it and are reachable only through
386
+ `${CLAUDE_SKILL_DIR}`. A relative path such as `python3 scripts/foo.py`
387
+ resolves against the user's working directory, and a repo-relative one such
388
+ as `app/skills/<name>/scripts/foo.py` against a tree the user does not have.
389
+ Both look correct in the source and fail for every installed user.
390
+
391
+ Nine skills shipped with one of those forms before this check existed.
392
+ `_validate_skill_references` never caught them because it asks whether the
393
+ file is on disk, not whether the documented command can find it.
394
+
395
+ Only executable invocations are checked. The frontmatter `scripts:` list
396
+ declares ownership and is correctly relative; prose that names a script in
397
+ backticks runs nothing.
398
+ """
399
+ name = skill_path.name
400
+ scripts_dir = skill_path / "scripts"
401
+ if not scripts_dir.is_dir():
402
+ return
403
+ owned = {p.name for p in scripts_dir.iterdir() if p.is_file()}
404
+ if not owned:
405
+ return
406
+
407
+ skill_file = skill_path / "SKILL.md"
408
+ text = skill_file.read_text(encoding="utf-8")
409
+
410
+ # Skip the frontmatter block: `scripts:` entries there are declarations.
411
+ lines = text.splitlines()
412
+ start = 0
413
+ if lines and lines[0].strip() == "---":
414
+ for idx in range(1, len(lines)):
415
+ if lines[idx].strip() == "---":
416
+ start = idx + 1
417
+ break
418
+
419
+ interpreters = {"python3": {".py"}, "python": {".py"}, "bash": {".sh"}, "sh": {".sh"}}
420
+ invocation = re.compile(r"\b(python3|python|bash|sh)\s+(\S+)")
421
+
422
+ for offset, line in enumerate(lines[start:], start=start + 1):
423
+ for interp, target in invocation.findall(line):
424
+ base = target.rsplit("/", 1)[-1].strip("\"'`")
425
+ if base not in owned:
426
+ continue # a repo-level script such as scripts/validate.py
427
+ if "${CLAUDE_SKILL_DIR}" not in target:
428
+ vr.error(
429
+ f"skills/{name}/SKILL.md:{offset}: runs its own '{base}' via "
430
+ f"'{target}' - use ${{CLAUDE_SKILL_DIR}}/scripts/{base}, which "
431
+ f"is the only path that resolves after install"
432
+ )
433
+ suffix = base[base.rfind("."):] if "." in base else ""
434
+ allowed = interpreters.get(interp, set())
435
+ if suffix and allowed and suffix not in allowed:
436
+ vr.error(
437
+ f"skills/{name}/SKILL.md:{offset}: runs '{base}' with "
438
+ f"'{interp}' - wrong interpreter for a {suffix} file"
439
+ )
440
+ elif interp == "python":
441
+ vr.warn(
442
+ f"skills/{name}/SKILL.md:{offset}: uses 'python' for '{base}' "
443
+ f"- prefer 'python3', 'python' is absent or Python 2 on many systems"
444
+ )
445
+
446
+
340
447
  def _validate_skill_references(tk_dir: Path, skill_path: Path,
341
448
  fm_lines: list[str], vr: ValidationResult) -> None:
342
449
  """Validate agent refs, depends-on, context/agent co-occurrence, and reference links."""
@@ -370,6 +477,8 @@ def _validate_skill_references(tk_dir: Path, skill_path: Path,
370
477
  if _fm_has(fm_lines, "run-mode"):
371
478
  vr.warn(f"{name} - Uses deprecated 'run-mode' field (rename to 'context:')")
372
479
 
480
+ _validate_skill_script_invocations(skill_path, vr)
481
+
373
482
  ref_dir = skill_path / "reference"
374
483
  if ref_dir.is_dir():
375
484
  content = skill_file.read_text(encoding="utf-8")
@@ -384,6 +493,7 @@ def validate_skills(tk_dir: Path, vr: ValidationResult) -> int:
384
493
  print("## Skills")
385
494
  skills_dir = tk_dir / "app" / "skills"
386
495
  skill_count = 0
496
+ largest_body = (0, "")
387
497
 
388
498
  if not skills_dir.is_dir():
389
499
  vr.error("app/skills directory not found")
@@ -408,8 +518,15 @@ def validate_skills(tk_dir: Path, vr: ValidationResult) -> int:
408
518
  fm_lines = _parse_frontmatter_lines(skill_file)
409
519
  _validate_skill_frontmatter(tk_dir, skill_path, fm_lines, vr)
410
520
  _validate_skill_references(tk_dir, skill_path, fm_lines, vr)
521
+ largest_body = max(largest_body, (_body_bytes(skill_file), name))
411
522
 
412
523
  print(f" Found: {skill_count} skills")
524
+ if skill_count:
525
+ size, worst = largest_body
526
+ print(
527
+ f" Body budget: largest is {worst} at {size} bytes "
528
+ f"(warn {SKILL_BODY_BUDGET_WARN}, error {SKILL_BODY_BUDGET_ERROR})"
529
+ )
413
530
  print()
414
531
  return skill_count
415
532