@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.
- package/CHANGELOG.md +121 -0
- package/README.md +21 -19
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/claude-app/hooks/hooks.json +4 -2
- package/app/claude-app/skills/ai-toolkit-rules/SKILL.md +30 -16
- package/app/hooks/quality-gate.sh +9 -2
- package/app/hooks.json +4 -2
- package/app/rules/common/git-team.md +33 -0
- package/app/rules/common/git-workflow.md +6 -20
- package/app/rules/common/performance.md +25 -1
- package/app/rules/common/testing.md +7 -1
- package/app/skills/analyze/scripts/complexity.py +3 -0
- package/app/skills/deploy/scripts/pre_deploy_check.py +13 -6
- package/app/skills/docs/scripts/doc-inventory.py +3 -0
- package/app/skills/explain/scripts/dependency-graph.py +3 -0
- package/app/skills/migrate/scripts/migration-status.py +3 -0
- package/app/skills/refactor/scripts/refactor-scan.py +3 -0
- package/benchmarks/ecosystem-doctor-snapshot.json +17 -15
- package/bin/ai-toolkit.js +2 -0
- package/kb/procedures/sop-maintenance.md +6 -3
- package/kb/reference/cli-reference.md +3 -2
- package/kb/reference/global-install-model.md +16 -3
- package/kb/reference/hooks-catalog.md +5 -3
- package/kb/reference/language-rules.md +28 -10
- package/kb/reference/plugin-pack-conventions.md +3 -3
- package/kb/reference/unique-features.md +2 -1
- package/llms-full.txt +63 -25
- package/manifest.json +2 -2
- package/package.json +5 -2
- package/scripts/benchmark_ecosystem.py +0 -1
- package/scripts/check_split.py +11 -9
- package/scripts/claude_app.py +5 -7
- package/scripts/codex_skill_adapter.py +4 -12
- package/scripts/compile_slm.py +10 -26
- package/scripts/doctor.py +322 -0
- package/scripts/evaluate_skills.py +1 -1
- package/scripts/frontmatter.py +452 -29
- package/scripts/generate_augment_rules.py +4 -4
- package/scripts/generate_cursor_mdc.py +2 -3
- package/scripts/generate_language_rules_skills.py +8 -14
- package/scripts/generate_llms_txt.py +1 -15
- package/scripts/generate_opencode_agents.py +0 -1
- package/scripts/generate_opencode_skills.py +2 -20
- package/scripts/generate_windsurf_rules.py +0 -1
- package/scripts/generator_base.py +0 -1
- package/scripts/inject_hook_cli.py +15 -2
- package/scripts/inject_mcp_cli.py +1 -2
- package/scripts/install.py +32 -1
- package/scripts/install_git_hooks.py +0 -1
- package/scripts/install_steps/ai_tools.py +65 -25
- package/scripts/install_steps/markers.py +6 -6
- package/scripts/install_steps/skill_scope.py +188 -0
- package/scripts/instruction_core.py +5 -8
- package/scripts/merge-hooks.py +13 -3
- package/scripts/pack_codebase.py +1 -1
- package/scripts/plugin.py +128 -16
- package/scripts/surface_manifest.py +6 -7
- 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,
|
|
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]] = [
|