@softspark/ai-toolkit 4.31.0 → 4.32.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +26 -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/benchmarks/ecosystem-doctor-snapshot.json +17 -15
  13. package/bin/ai-toolkit.js +2 -0
  14. package/kb/procedures/sop-maintenance.md +6 -3
  15. package/kb/reference/cli-reference.md +3 -2
  16. package/kb/reference/global-install-model.md +16 -3
  17. package/kb/reference/hooks-catalog.md +5 -3
  18. package/kb/reference/language-rules.md +28 -10
  19. package/kb/reference/unique-features.md +2 -1
  20. package/llms-full.txt +60 -22
  21. package/manifest.json +2 -2
  22. package/package.json +5 -2
  23. package/scripts/benchmark_ecosystem.py +0 -1
  24. package/scripts/check_split.py +11 -9
  25. package/scripts/claude_app.py +5 -7
  26. package/scripts/codex_skill_adapter.py +4 -12
  27. package/scripts/compile_slm.py +10 -26
  28. package/scripts/doctor.py +322 -0
  29. package/scripts/evaluate_skills.py +1 -1
  30. package/scripts/frontmatter.py +452 -29
  31. package/scripts/generate_augment_rules.py +4 -4
  32. package/scripts/generate_cursor_mdc.py +2 -3
  33. package/scripts/generate_language_rules_skills.py +8 -14
  34. package/scripts/generate_llms_txt.py +1 -15
  35. package/scripts/generate_opencode_agents.py +0 -1
  36. package/scripts/generate_opencode_skills.py +2 -20
  37. package/scripts/generate_windsurf_rules.py +0 -1
  38. package/scripts/generator_base.py +0 -1
  39. package/scripts/inject_hook_cli.py +15 -2
  40. package/scripts/inject_mcp_cli.py +1 -2
  41. package/scripts/install.py +32 -1
  42. package/scripts/install_git_hooks.py +0 -1
  43. package/scripts/install_steps/ai_tools.py +65 -25
  44. package/scripts/install_steps/markers.py +6 -6
  45. package/scripts/install_steps/skill_scope.py +188 -0
  46. package/scripts/instruction_core.py +5 -8
  47. package/scripts/merge-hooks.py +13 -3
  48. package/scripts/pack_codebase.py +1 -1
  49. package/scripts/surface_manifest.py +6 -7
  50. package/scripts/validate.py +180 -11
@@ -35,17 +35,16 @@ import re
35
35
  import sys
36
36
  import urllib.request
37
37
  import urllib.error
38
- from dataclasses import dataclass, field
38
+ from dataclasses import dataclass
39
39
  from pathlib import Path
40
40
 
41
41
  sys.path.insert(0, str(Path(__file__).resolve().parent))
42
+ from frontmatter import split_frontmatter
42
43
  from _common import (
43
- toolkit_dir,
44
44
  app_dir,
45
45
  agents_dir,
46
46
  skills_dir,
47
47
  frontmatter_field,
48
- frontmatter_block,
49
48
  )
50
49
 
51
50
  # ---------------------------------------------------------------------------
@@ -209,16 +208,6 @@ def _read_file_text(path: Path) -> str:
209
208
  return ""
210
209
 
211
210
 
212
- def _strip_frontmatter(text: str) -> str:
213
- """Remove YAML frontmatter (--- delimited) from text."""
214
- if not text.startswith("---"):
215
- return text
216
- end = text.find("---", 3)
217
- if end == -1:
218
- return text
219
- return text[end + 3:].lstrip("\n")
220
-
221
-
222
211
  def _parse_persona_skills(persona_path: Path) -> list[str]:
223
212
  """Extract preferred skill names from a persona file."""
224
213
  text = _read_file_text(persona_path)
@@ -252,7 +241,7 @@ def parse_components(
252
241
  constitution_path = app_dir / "constitution.md"
253
242
  if constitution_path.is_file():
254
243
  text = _read_file_text(constitution_path)
255
- body = _strip_frontmatter(text)
244
+ body = split_frontmatter(text)[1].lstrip("\n")
256
245
  components.append(Component(
257
246
  name="Constitution",
258
247
  type="constitution",
@@ -289,7 +278,7 @@ def parse_components(
289
278
  # --- Persona definition (score=0.90) ---
290
279
  if persona_path and persona_path.is_file():
291
280
  text = _read_file_text(persona_path)
292
- body = _strip_frontmatter(text)
281
+ body = split_frontmatter(text)[1].lstrip("\n")
293
282
  components.append(Component(
294
283
  name=f"Persona: {persona}",
295
284
  type="persona",
@@ -310,7 +299,7 @@ def parse_components(
310
299
  if common_rules_dir.is_dir():
311
300
  for rule_file in sorted(common_rules_dir.glob("*.md")):
312
301
  text = _read_file_text(rule_file)
313
- body = _strip_frontmatter(text)
302
+ body = split_frontmatter(text)[1].lstrip("\n")
314
303
  components.append(Component(
315
304
  name=f"Rule: common/{rule_file.stem}",
316
305
  type="rule",
@@ -332,7 +321,7 @@ def parse_components(
332
321
  continue
333
322
  for rule_file in sorted(lang_dir.glob("*.md")):
334
323
  text = _read_file_text(rule_file)
335
- body = _strip_frontmatter(text)
324
+ body = split_frontmatter(text)[1].lstrip("\n")
336
325
  components.append(Component(
337
326
  name=f"Rule: {lang}/{rule_file.stem}",
338
327
  type="rule",
@@ -355,7 +344,6 @@ def parse_components(
355
344
  skill_name = frontmatter_field(skill_file, "name")
356
345
  if not skill_name:
357
346
  skill_name = skill_dir.name
358
- description = frontmatter_field(skill_file, "description")
359
347
  user_invocable = frontmatter_field(skill_file, "user-invocable")
360
348
 
361
349
  # Skip non-user-invocable knowledge skills for SLM
@@ -364,7 +352,7 @@ def parse_components(
364
352
 
365
353
  # Build a compact skill summary (name + description)
366
354
  text = _read_file_text(skill_file)
367
- body = _strip_frontmatter(text)
355
+ body = split_frontmatter(text)[1].lstrip("\n")
368
356
 
369
357
  # Persona relevance boost
370
358
  p_relevance = 0.7 if skill_name in persona_skills else 0.3
@@ -391,9 +379,8 @@ def parse_components(
391
379
  agent_name = frontmatter_field(agent_file, "name")
392
380
  if not agent_name:
393
381
  agent_name = agent_file.stem
394
- description = frontmatter_field(agent_file, "description")
395
382
  text = _read_file_text(agent_file)
396
- body = _strip_frontmatter(text)
383
+ body = split_frontmatter(text)[1].lstrip("\n")
397
384
 
398
385
  # Persona relevance: match if agent skills overlap persona skills
399
386
  agent_skills_str = frontmatter_field(agent_file, "skills")
@@ -457,7 +444,7 @@ def compress_component(component: Component, level_config: dict[str, object]) ->
457
444
  return
458
445
 
459
446
  # Strip frontmatter (already done in parsing, but safety check)
460
- text = _strip_frontmatter(text)
447
+ text = split_frontmatter(text)[1].lstrip("\n")
461
448
 
462
449
  # Strip examples
463
450
  strip_examples = level_config.get("strip_examples", True)
@@ -611,9 +598,6 @@ def pack_components(
611
598
 
612
599
  # Constitution budget guard
613
600
  if fixed_tokens > effective_budget:
614
- constitution_tokens = sum(
615
- c.tokens_compressed for c in fixed if c.type == "constitution"
616
- )
617
601
  print(
618
602
  f"ERROR: Constitution + safety rules alone require {fixed_tokens} tokens, "
619
603
  f"exceeding budget of {effective_budget} (budget={budget} × {BUDGET_SAFETY_MARGIN}).\n"
@@ -995,7 +979,7 @@ def main(argv: list[str] | None = None) -> int:
995
979
  level = args.level or str(model_config["level"])
996
980
 
997
981
  # Parse languages
998
- languages = [l.strip() for l in args.lang.split(",") if l.strip()] if args.lang else []
982
+ languages = [lang.strip() for lang in args.lang.split(",") if lang.strip()] if args.lang else []
999
983
 
1000
984
  # Parse components
1001
985
  components = parse_components(persona=args.persona, languages=languages)
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]] = [