@softspark/ai-toolkit 4.31.0 → 4.32.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 (58) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +21 -19
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/claude-app/hooks/hooks.json +4 -2
  5. package/app/claude-app/skills/ai-toolkit-rules/SKILL.md +30 -16
  6. package/app/hooks/quality-gate.sh +9 -2
  7. package/app/hooks.json +4 -2
  8. package/app/rules/common/git-team.md +33 -0
  9. package/app/rules/common/git-workflow.md +6 -20
  10. package/app/rules/common/performance.md +25 -1
  11. package/app/rules/common/testing.md +7 -1
  12. package/app/skills/analyze/scripts/complexity.py +3 -0
  13. package/app/skills/deploy/scripts/pre_deploy_check.py +13 -6
  14. package/app/skills/docs/scripts/doc-inventory.py +3 -0
  15. package/app/skills/explain/scripts/dependency-graph.py +3 -0
  16. package/app/skills/migrate/scripts/migration-status.py +3 -0
  17. package/app/skills/refactor/scripts/refactor-scan.py +3 -0
  18. package/benchmarks/ecosystem-doctor-snapshot.json +17 -15
  19. package/bin/ai-toolkit.js +2 -0
  20. package/kb/procedures/sop-maintenance.md +6 -3
  21. package/kb/reference/cli-reference.md +3 -2
  22. package/kb/reference/global-install-model.md +16 -3
  23. package/kb/reference/hooks-catalog.md +5 -3
  24. package/kb/reference/language-rules.md +28 -10
  25. package/kb/reference/plugin-pack-conventions.md +3 -3
  26. package/kb/reference/unique-features.md +2 -1
  27. package/llms-full.txt +63 -25
  28. package/manifest.json +2 -2
  29. package/package.json +5 -2
  30. package/scripts/benchmark_ecosystem.py +0 -1
  31. package/scripts/check_split.py +11 -9
  32. package/scripts/claude_app.py +5 -7
  33. package/scripts/codex_skill_adapter.py +4 -12
  34. package/scripts/compile_slm.py +10 -26
  35. package/scripts/doctor.py +322 -0
  36. package/scripts/evaluate_skills.py +1 -1
  37. package/scripts/frontmatter.py +452 -29
  38. package/scripts/generate_augment_rules.py +4 -4
  39. package/scripts/generate_cursor_mdc.py +2 -3
  40. package/scripts/generate_language_rules_skills.py +8 -14
  41. package/scripts/generate_llms_txt.py +1 -15
  42. package/scripts/generate_opencode_agents.py +0 -1
  43. package/scripts/generate_opencode_skills.py +2 -20
  44. package/scripts/generate_windsurf_rules.py +0 -1
  45. package/scripts/generator_base.py +0 -1
  46. package/scripts/inject_hook_cli.py +15 -2
  47. package/scripts/inject_mcp_cli.py +1 -2
  48. package/scripts/install.py +32 -1
  49. package/scripts/install_git_hooks.py +0 -1
  50. package/scripts/install_steps/ai_tools.py +65 -25
  51. package/scripts/install_steps/markers.py +6 -6
  52. package/scripts/install_steps/skill_scope.py +188 -0
  53. package/scripts/instruction_core.py +5 -8
  54. package/scripts/merge-hooks.py +13 -3
  55. package/scripts/pack_codebase.py +1 -1
  56. package/scripts/plugin.py +128 -16
  57. package/scripts/surface_manifest.py +6 -7
  58. package/scripts/validate.py +180 -11
package/scripts/doctor.py CHANGED
@@ -36,7 +36,9 @@ from pathlib import Path
36
36
 
37
37
  sys.path.insert(0, str(Path(__file__).resolve().parent))
38
38
  from _common import toolkit_dir
39
+ from frontmatter import FrontmatterError, load_frontmatter
39
40
  from paths import HOOKS_DIR as _HOOKS_DIR, RULES_DIR as _RULES_DIR, EXTERNAL_HOOKS_DIR as _EXTERNAL_HOOKS_DIR
41
+ from paths import STATS_FILE as _STATS_FILE
40
42
 
41
43
 
42
44
  # ---------------------------------------------------------------------------
@@ -49,6 +51,16 @@ RULES_DIR = _RULES_DIR
49
51
  EXTERNAL_HOOKS_DIR = _EXTERNAL_HOOKS_DIR
50
52
  BENCHMARK_DASHBOARD = toolkit_dir / "benchmarks" / "ecosystem-dashboard.json"
51
53
  PLUGIN_REGISTRY = CLAUDE_DIR / "plugins" / "installed_plugins.json"
54
+ STATS_FILE = _STATS_FILE
55
+
56
+ # Context budget (check 12). Estimates only: tokens ~= characters / 4.
57
+ # Claude Code caps the model-visible skill listing at a fraction of the context
58
+ # window (settings key `skillListingBudgetFraction`, default 1%). The window
59
+ # size is not readable from disk, so the smallest production window is assumed.
60
+ CONTEXT_WINDOW_TOKENS = 200_000
61
+ DEFAULT_SKILL_LISTING_FRACTION = 0.01
62
+ # Below this many recorded startups a zero usage counter is thin evidence.
63
+ MIN_STARTUPS_FOR_USAGE_VERDICT = 200
52
64
 
53
65
  VALID_EVENTS = frozenset({
54
66
  "SessionStart", "Notification", "PreToolUse", "PostToolUse", "Stop",
@@ -157,6 +169,9 @@ class DiagResult:
157
169
  def fixed(self, msg: str) -> None:
158
170
  print(f" FIXED: {msg}")
159
171
 
172
+ def info(self, msg: str) -> None:
173
+ print(f" INFO: {msg}")
174
+
160
175
 
161
176
  # ---------------------------------------------------------------------------
162
177
  # Version extraction
@@ -902,6 +917,311 @@ def check_plugin_double_load(dr: DiagResult, fix_mode: bool) -> None:
902
917
  dr.fixed(f"disabled {key} for Claude Code (global install stays authoritative)")
903
918
 
904
919
 
920
+ # ---------------------------------------------------------------------------
921
+ # Check 12: Context Budget
922
+ # ---------------------------------------------------------------------------
923
+
924
+ def _frontmatter_top_level(path: Path) -> dict[str, str]:
925
+ """Top-level scalar fields of a skill or agent file, as the listing sees them.
926
+
927
+ Tolerant: doctor describes a broken file rather than refusing it, so the
928
+ parser runs non-strict and anything it still rejects reads as empty.
929
+ Block scalars come back folded onto one line, which is how description
930
+ length is measured by the runtime; list and map values are dropped.
931
+ """
932
+ try:
933
+ data = load_frontmatter(path, strict=False)
934
+ except (OSError, FrontmatterError):
935
+ return {}
936
+ return {
937
+ key: value.replace("\n", " ").strip()
938
+ for key, value in data.items()
939
+ if isinstance(value, str)
940
+ }
941
+
942
+
943
+ def _est_tokens(chars: int) -> int:
944
+ return chars // 4
945
+
946
+
947
+ def _load_json_keys(path: Path, keys: tuple[str, ...]) -> dict:
948
+ """Read only the named top-level keys from a JSON file.
949
+
950
+ Settings files carry secrets next to the keys this check needs, so
951
+ nothing else leaves this function.
952
+ """
953
+ try:
954
+ with open(path, encoding="utf-8") as f:
955
+ data = json.load(f)
956
+ except (OSError, json.JSONDecodeError):
957
+ return {}
958
+ if not isinstance(data, dict):
959
+ return {}
960
+ return {k: data[k] for k in keys if k in data}
961
+
962
+
963
+ def _usage_counts(claude_json: Path, stats_file: Path) -> tuple[dict[str, int], int | None]:
964
+ """Merge Claude Code's lifetime skill counters with the toolkit's stats.json.
965
+
966
+ Returns ``(counts by skill name, numStartups or None when unknown)``.
967
+ Both sources are lifetime totals, never windowed.
968
+ """
969
+ counts: dict[str, int] = {}
970
+ startups: int | None = None
971
+ cc = _load_json_keys(claude_json, ("skillUsage", "numStartups"))
972
+ usage = cc.get("skillUsage")
973
+ if isinstance(usage, dict):
974
+ for name, info in usage.items():
975
+ if isinstance(info, dict):
976
+ n = info.get("usageCount", 0)
977
+ if isinstance(n, int):
978
+ # Nested skills are keyed "<dir>:<name>"; count under both.
979
+ bare = str(name).rsplit(":", 1)[-1]
980
+ counts[bare] = counts.get(bare, 0) + n
981
+ if isinstance(cc.get("numStartups"), int):
982
+ startups = cc["numStartups"]
983
+ try:
984
+ with open(stats_file, encoding="utf-8") as f:
985
+ raw = json.load(f)
986
+ except (OSError, json.JSONDecodeError):
987
+ raw = {}
988
+ if isinstance(raw, dict):
989
+ for name, info in raw.items():
990
+ if isinstance(info, dict) and isinstance(info.get("count"), int):
991
+ counts[str(name)] = counts.get(str(name), 0) + info["count"]
992
+ return counts, startups
993
+
994
+
995
+ def check_context_budget(dr: DiagResult) -> None:
996
+ """Estimate always-resident context and list skills with no recorded use.
997
+
998
+ Read-only by design: the verdict on a zero-use skill belongs to the user.
999
+ The output ends with the exact ``skillOverrides`` key to paste.
1000
+ """
1001
+ print()
1002
+ print("## 12. Context Budget")
1003
+
1004
+ skills_dir = CLAUDE_DIR / "skills"
1005
+ if not skills_dir.is_dir():
1006
+ dr.skip("No ~/.claude/skills directory")
1007
+ return
1008
+
1009
+ settings = _load_json_keys(
1010
+ CLAUDE_DIR / "settings.json",
1011
+ ("skillOverrides", "skillListingBudgetFraction"),
1012
+ )
1013
+ overrides = settings.get("skillOverrides")
1014
+ disabled = {
1015
+ name for name, state in overrides.items() if state == "off"
1016
+ } if isinstance(overrides, dict) else set()
1017
+ fraction = settings.get("skillListingBudgetFraction")
1018
+ if not isinstance(fraction, (int, float)) or fraction <= 0:
1019
+ fraction = DEFAULT_SKILL_LISTING_FRACTION
1020
+
1021
+ # Skill listing: name + description of every model-invocable skill.
1022
+ listing_chars = 0
1023
+ listed: dict[str, int] = {}
1024
+ for skill_dir in sorted(skills_dir.iterdir()):
1025
+ skill_file = skill_dir / "SKILL.md"
1026
+ if not skill_file.is_file() or skill_dir.name in disabled:
1027
+ continue
1028
+ fm = _frontmatter_top_level(skill_file)
1029
+ if fm.get("disable-model-invocation", "").lower() == "true":
1030
+ continue
1031
+ chars = len(fm.get("name", skill_dir.name)) + len(fm.get("description", ""))
1032
+ listed[skill_dir.name] = chars
1033
+ listing_chars += chars
1034
+
1035
+ budget_tokens = int(CONTEXT_WINDOW_TOKENS * fraction)
1036
+ listing_tokens = _est_tokens(listing_chars)
1037
+ listing_msg = (
1038
+ f"skill listing: {len(listed)} model-invocable skills, "
1039
+ f"~{listing_tokens} est. tokens "
1040
+ f"(budget ~{budget_tokens} at {fraction:.0%} of a {CONTEXT_WINDOW_TOKENS // 1000}k window)"
1041
+ )
1042
+ if listing_tokens > budget_tokens:
1043
+ dr.warn(listing_msg + " - over budget on a 200k-window model, entries get truncated "
1044
+ "and skill routing degrades")
1045
+ else:
1046
+ dr.ok(listing_msg)
1047
+
1048
+ # Agents listing.
1049
+ agents_dir = CLAUDE_DIR / "agents"
1050
+ if agents_dir.is_dir():
1051
+ agent_chars = 0
1052
+ agent_count = 0
1053
+ for agent_file in sorted(agents_dir.glob("*.md")):
1054
+ fm = _frontmatter_top_level(agent_file)
1055
+ if "name" not in fm:
1056
+ continue
1057
+ agent_count += 1
1058
+ agent_chars += len(fm["name"]) + len(fm.get("description", ""))
1059
+ dr.ok(f"agents listing: {agent_count} agents, ~{_est_tokens(agent_chars)} est. tokens")
1060
+
1061
+ # Always-loaded user memory: ~/.claude/CLAUDE.md and ~/.claude/rules/*.md.
1062
+ memory_files = [CLAUDE_DIR / "CLAUDE.md"] + sorted((CLAUDE_DIR / "rules").glob("*.md")) \
1063
+ if (CLAUDE_DIR / "rules").is_dir() else [CLAUDE_DIR / "CLAUDE.md"]
1064
+ memory_chars = sum(p.stat().st_size for p in memory_files if p.is_file())
1065
+ memory_count = sum(1 for p in memory_files if p.is_file())
1066
+ dr.ok(
1067
+ f"user memory: {memory_count} always-loaded files "
1068
+ f"(~/.claude/CLAUDE.md + rules), ~{_est_tokens(memory_chars)} est. tokens"
1069
+ )
1070
+
1071
+ # Zero-use skills. Evidence: Claude Code lifetime counters + toolkit stats.
1072
+ claude_json = Path.home() / ".claude.json"
1073
+ counts, startups = _usage_counts(claude_json, STATS_FILE)
1074
+ if not claude_json.is_file():
1075
+ dr.skip("usage evidence: ~/.claude.json not found, cannot judge unused skills")
1076
+ return
1077
+ if startups is None or startups < MIN_STARTUPS_FOR_USAGE_VERDICT:
1078
+ dr.skip(
1079
+ f"usage evidence: {startups or 0} startups recorded "
1080
+ f"(need {MIN_STARTUPS_FOR_USAGE_VERDICT} before a zero counter means anything)"
1081
+ )
1082
+ return
1083
+
1084
+ unused = sorted(name for name in listed if counts.get(name, 0) == 0)
1085
+ used = len(listed) - len(unused)
1086
+ dr.ok(f"usage evidence: {startups} startups, {used} of {len(listed)} listed skills ever dispatched")
1087
+ if not unused:
1088
+ return
1089
+
1090
+ unused_tokens = _est_tokens(sum(listed[n] for n in unused))
1091
+ # Language knowledge skills are generated from app/rules/<lang>/; a zero
1092
+ # there almost always means "not your stack", unlike domain skills.
1093
+ rules_root = toolkit_dir / "app" / "rules"
1094
+ languages = {
1095
+ p.name for p in rules_root.iterdir() if p.is_dir() and p.name != "common"
1096
+ } if rules_root.is_dir() else set()
1097
+ language = [
1098
+ n for n in unused
1099
+ if n.rsplit("-", 1)[-1] in ("rules", "patterns") and n.rsplit("-", 1)[0] in languages
1100
+ ]
1101
+ other = [n for n in unused if n not in language]
1102
+ dr.info(
1103
+ f"{len(unused)} listed skills have zero recorded use over {startups} startups "
1104
+ f"(~{unused_tokens} est. tokens in every session)"
1105
+ )
1106
+ if language:
1107
+ dr.info("language skills (safe to turn off for stacks you do not work in): "
1108
+ + ", ".join(language))
1109
+ if other:
1110
+ dr.info("other zero-use skills (a zero here may mean never triggered, not useless): "
1111
+ + ", ".join(other))
1112
+ dr.info('to turn one off: ~/.claude/settings.json -> "skillOverrides": {"<name>": "off"} '
1113
+ "(reversible; doctor never writes this for you)")
1114
+
1115
+
1116
+ # ---------------------------------------------------------------------------
1117
+ # Check 13: Permission Rules
1118
+ # ---------------------------------------------------------------------------
1119
+
1120
+ # Bash allow rules are prefix string matches with no flag analysis. A wildcard
1121
+ # on any of these pre-approves arbitrary code execution or writes, whatever the
1122
+ # rule's author had in mind when they clicked "always allow".
1123
+ _EXEC_COMMANDS = frozenset({
1124
+ "python", "python3", "node", "deno", "bun", "ruby", "perl", "php",
1125
+ "bash", "sh", "zsh", "fish", "npx", "bunx", "pnpx", "eval", "exec", "xargs",
1126
+ "ssh", "sudo", "su", "docker", "kubectl",
1127
+ })
1128
+ _TASK_RUNNERS = frozenset({"npm run", "yarn", "pnpm run", "make", "just", "task", "rake", "gradle", "mvn", "cargo run"})
1129
+ # Package installs run lifecycle scripts (postinstall, build.rs, setup.py).
1130
+ _PACKAGE_INSTALLS = frozenset({
1131
+ "npm install", "npm i", "npm ci", "pnpm install", "pnpm add", "yarn add",
1132
+ "pip install", "pip3 install", "uv pip", "cargo install", "gem install", "composer install",
1133
+ })
1134
+ _NETWORK_FETCHERS = frozenset({"curl", "wget", "http", "nc", "ncat", "netcat"})
1135
+ _GIT_EXEC_SUBCOMMANDS = frozenset({"git fetch", "git pull", "git clone", "git submodule"})
1136
+ _DESTRUCTIVE = frozenset({"rm", "rmdir", "mv", "dd", "mkfs", "shred", "truncate", "chmod", "chown"})
1137
+
1138
+
1139
+ def _bash_rule_body(rule: str) -> str | None:
1140
+ """Return the command pattern inside ``Bash(...)`` or None for other tools."""
1141
+ if not rule.startswith("Bash(") or not rule.endswith(")"):
1142
+ return None
1143
+ return rule[len("Bash("):-1].strip()
1144
+
1145
+
1146
+ def _classify_allow_rule(rule: str) -> str | None:
1147
+ """Return why an allow rule is over-broad, or None when it looks fine.
1148
+
1149
+ Only patterns are judged, never the user's intent: an exact rule is
1150
+ left alone even for an interpreter, since it pre-approves one string.
1151
+ Wildcards (``*`` or trailing ``:*``) are what turn a rule into a class.
1152
+ """
1153
+ body = _bash_rule_body(rule)
1154
+ if body is None:
1155
+ return None
1156
+ wildcard = "*" in body
1157
+ if not wildcard:
1158
+ return None
1159
+ head = body.replace(":*", " *").rstrip("* ").strip()
1160
+ tokens = head.split()
1161
+ if not tokens:
1162
+ return "matches every Bash command"
1163
+ first = tokens[0]
1164
+ two = " ".join(tokens[:2])
1165
+ if first in _EXEC_COMMANDS:
1166
+ return f"wildcard on interpreter/executor '{first}' (arbitrary code execution)"
1167
+ if two in _TASK_RUNNERS or first in _TASK_RUNNERS:
1168
+ return f"wildcard on task runner '{two if two in _TASK_RUNNERS else first}' (runs whatever the project script says)"
1169
+ if two in _PACKAGE_INSTALLS:
1170
+ return f"wildcard on '{two}' (package lifecycle scripts execute on install)"
1171
+ if first in _NETWORK_FETCHERS:
1172
+ return f"wildcard on '{first}' (can POST data out or fetch and pipe code)"
1173
+ if two == "gh api":
1174
+ return "wildcard on 'gh api' (also matches POST/DELETE and GraphQL mutations)"
1175
+ if two in _GIT_EXEC_SUBCOMMANDS:
1176
+ return f"wildcard on '{two}' (remote helpers and ext:: URLs execute commands)"
1177
+ if first in _DESTRUCTIVE:
1178
+ return f"wildcard on '{first}' (destructive)"
1179
+ if first == "find" and ("-exec" in body or "-delete" in body):
1180
+ return "find with -exec/-delete"
1181
+ return None
1182
+
1183
+
1184
+ def check_permission_rules(dr: DiagResult) -> None:
1185
+ """Flag allow rules that pre-approve more than a read-only class of commands.
1186
+
1187
+ Read-only. Deny/ask rules are never touched and never flagged.
1188
+ """
1189
+ print()
1190
+ print("## 13. Permission Rules")
1191
+
1192
+ candidates = [
1193
+ ("~/.claude/settings.json", CLAUDE_DIR / "settings.json"),
1194
+ (".claude/settings.json", Path.cwd() / ".claude" / "settings.json"),
1195
+ (".claude/settings.local.json", Path.cwd() / ".claude" / "settings.local.json"),
1196
+ ]
1197
+ seen_any = False
1198
+ flagged = 0
1199
+ for label, path in candidates:
1200
+ if not path.is_file():
1201
+ continue
1202
+ perms = _load_json_keys(path, ("permissions",)).get("permissions")
1203
+ if not isinstance(perms, dict):
1204
+ continue
1205
+ allow = perms.get("allow")
1206
+ if not isinstance(allow, list):
1207
+ continue
1208
+ seen_any = True
1209
+ for rule in allow:
1210
+ if not isinstance(rule, str):
1211
+ continue
1212
+ reason = _classify_allow_rule(rule)
1213
+ if reason:
1214
+ flagged += 1
1215
+ dr.warn(f"{label}: allow rule {rule} - {reason}")
1216
+ if not seen_any:
1217
+ dr.skip("no permissions.allow lists in user or project settings")
1218
+ return
1219
+ if flagged == 0:
1220
+ dr.ok("allow rules pre-approve nothing beyond read-only commands")
1221
+ else:
1222
+ dr.info("doctor never edits permissions; remove a rule from its file, or narrow it to the exact command you meant")
1223
+
1224
+
905
1225
  # ---------------------------------------------------------------------------
906
1226
  # Main
907
1227
  # ---------------------------------------------------------------------------
@@ -927,6 +1247,8 @@ def main() -> None:
927
1247
  check_url_hooks(dr, fix_mode)
928
1248
  check_language_drift(dr)
929
1249
  check_plugin_double_load(dr, fix_mode)
1250
+ check_context_budget(dr)
1251
+ check_permission_rules(dr)
930
1252
 
931
1253
  # Summary
932
1254
  print("========================")
@@ -19,7 +19,7 @@ import sys
19
19
  from pathlib import Path
20
20
 
21
21
  sys.path.insert(0, str(Path(__file__).resolve().parent))
22
- from _common import frontmatter_block, frontmatter_field, skills_dir
22
+ from _common import frontmatter_block, skills_dir
23
23
 
24
24
  # Deprecated frontmatter fields and their replacements
25
25
  _DEPRECATED_FIELDS: list[tuple[str, str]] = [