@softspark/ai-toolkit 4.30.3 → 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.
- package/CHANGELOG.md +123 -0
- package/README.md +32 -25
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/agents/code-reviewer.md +14 -0
- package/app/agents/frontend-specialist.md +27 -24
- package/app/agents/infrastructure-validator.md +2 -2
- package/app/claude-app/hooks/hooks.json +4 -2
- package/app/claude-app/skills/ai-toolkit-rules/SKILL.md +74 -17
- package/app/hooks/quality-gate.sh +9 -2
- package/app/hooks.json +4 -2
- package/app/personas/frontend-lead.md +20 -12
- package/app/rules/claude-toolkit-rules.md +4 -1
- 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/rules/edit-discipline.md +37 -0
- package/app/skills/app-builder/SKILL.md +37 -6
- package/app/skills/claude-toolkit-rules/SKILL.md +37 -0
- package/app/skills/design-engineering/SKILL.md +87 -102
- package/app/skills/design-engineering/reference/animation-recipes.md +67 -0
- package/app/skills/design-engineering/reference/component-craft.md +28 -0
- package/app/skills/documentation-standards/SKILL.md +1 -1
- package/app/skills/edit-discipline/SKILL.md +50 -0
- package/app/skills/git-conventions/SKILL.md +18 -0
- package/app/skills/output-mode/SKILL.md +41 -0
- package/app/skills/quality-gates/SKILL.md +23 -0
- package/app/skills/review/SKILL.md +10 -0
- package/benchmarks/ecosystem-doctor-snapshot.json +17 -15
- package/bin/ai-toolkit.js +2 -0
- package/kb/procedures/{ecosystem-sync-sop.md → sop-ecosystem-sync.md} +2 -2
- package/kb/procedures/{maintenance-sop.md → sop-maintenance.md} +6 -3
- package/kb/procedures/{post-release-testing-sop.md → sop-post-release-testing.md} +3 -3
- package/kb/procedures/sop-pre-commit.md +96 -0
- package/kb/procedures/{release-preparation-sop.md → sop-release.md} +6 -6
- package/kb/reference/architecture-overview.md +1 -1
- package/kb/reference/cli-reference.md +3 -2
- package/kb/reference/copilot-compatibility.md +1 -1
- 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/licensing.md +2 -2
- package/kb/reference/quick-wins-implementation-summary.md +1 -1
- package/kb/reference/supported-tools-registry.md +2 -2
- package/kb/reference/unique-features.md +2 -1
- package/kb/troubleshooting/plugin-double-load.md +1 -1
- package/llms-full.txt +1016 -871
- package/llms.txt +6 -5
- package/manifest.json +2 -2
- package/package.json +6 -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_toolkit_rules_skills.py +189 -0
- 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/surface_manifest.py +6 -7
- package/scripts/validate.py +181 -12
- /package/kb/procedures/{release-verification-sop.md → sop-release-verification.md} +0 -0
package/llms.txt
CHANGED
|
@@ -32,11 +32,12 @@
|
|
|
32
32
|
- [Closed: Tool-Output Token Reduction](kb/history/completed/tool-output-token-reduction-closed-20260727.md)
|
|
33
33
|
- [How-To Guides](kb/howto/README.md)
|
|
34
34
|
- [Plan: Cloud Security Pack — Multi-Cloud Audit](kb/planning/cloud-security-pack-plan.md)
|
|
35
|
-
- [SOP: Ecosystem Sync](kb/procedures/ecosystem-sync
|
|
36
|
-
- [SOP: AI Toolkit Maintenance](kb/procedures/maintenance
|
|
37
|
-
- [SOP: Post-Release Testing](kb/procedures/post-release-testing
|
|
38
|
-
- [SOP:
|
|
39
|
-
- [SOP: Release Verification](kb/procedures/release-verification
|
|
35
|
+
- [SOP: Ecosystem Sync](kb/procedures/sop-ecosystem-sync.md)
|
|
36
|
+
- [SOP: AI Toolkit Maintenance](kb/procedures/sop-maintenance.md)
|
|
37
|
+
- [SOP: Post-Release Testing](kb/procedures/sop-post-release-testing.md)
|
|
38
|
+
- [SOP: Pre-Commit Gate](kb/procedures/sop-pre-commit.md)
|
|
39
|
+
- [SOP: Release Verification](kb/procedures/sop-release-verification.md)
|
|
40
|
+
- [SOP: Release Preparation](kb/procedures/sop-release.md)
|
|
40
41
|
- [Agents Catalog](kb/reference/agents-catalog.md)
|
|
41
42
|
- [Anti-Pattern Registry Format](kb/reference/anti-pattern-registry-format.md)
|
|
42
43
|
- [AI Toolkit Architecture](kb/reference/architecture-overview.md)
|
package/manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "4.
|
|
2
|
+
"version": "4.32.0",
|
|
3
3
|
"components": {
|
|
4
4
|
"agents": {
|
|
5
5
|
"description": "44 specialized agents (orchestrator, backend, frontend, security, devops, etc.)",
|
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
"default": true
|
|
142
142
|
},
|
|
143
143
|
"rules-common": {
|
|
144
|
-
"description": "Common coding rules (
|
|
144
|
+
"description": "Common coding rules (6 files; git-team ships with the strict profile only)",
|
|
145
145
|
"default": true
|
|
146
146
|
},
|
|
147
147
|
"rules-typescript": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softspark/ai-toolkit",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.32.0",
|
|
4
4
|
"description": "AI coding toolkit: 109 skills, 44 agents, 14 developer-tool integrations, recoverable native tool-output filtering, Claude Chat/Cowork export, safety constitution, SARIF audit, and signed npm provenance.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"generate:roo": "python3 scripts/generate_roo_modes.py > .roomodes",
|
|
63
63
|
"generate:aider": "python3 scripts/generate_aider_conf.py > .aider.conf.yml",
|
|
64
64
|
"generate:language-rules": "python3 scripts/generate_language_rules_skills.py",
|
|
65
|
+
"generate:toolkit-rules": "python3 scripts/generate_toolkit_rules_skills.py",
|
|
65
66
|
"generate:opencode-agents": "python3 scripts/generate_opencode_agents.py .",
|
|
66
67
|
"generate:opencode-commands": "python3 scripts/generate_opencode_commands.py .",
|
|
67
68
|
"generate:opencode-skills": "python3 scripts/generate_opencode_skills.py .",
|
|
@@ -74,7 +75,10 @@
|
|
|
74
75
|
"generate:all": "npm run generate:language-rules && npm run generate:claude-app && npm run generate:agents && npm run generate:opencode-agents && npm run generate:opencode-commands && npm run generate:opencode-skills && npm run generate:cursor && npm run generate:cursor-mdc && npm run generate:windsurf && npm run generate:windsurf-rules && npm run generate:copilot && npm run generate:gemini && npm run generate:gemini-agents && npm run generate:cline && npm run generate:cline-hooks && npm run generate:roo && npm run generate:roo-rules && npm run generate:aider && npm run generate:augment-rules && npm run generate:antigravity && npm run generate:llms && npm run generate:badges",
|
|
75
76
|
"generate:badges": "python3 scripts/sync_badges.py",
|
|
76
77
|
"surface": "python3 scripts/surface_manifest.py",
|
|
77
|
-
"surface:update": "python3 scripts/surface_manifest.py --update"
|
|
78
|
+
"surface:update": "python3 scripts/surface_manifest.py --update",
|
|
79
|
+
"test:py": "python3 -m pytest tests/python",
|
|
80
|
+
"lint:py": "ruff check scripts --select E,F --ignore E501,E402 --line-length 120 --extend-exclude app/skills/*/scripts,benchmarks",
|
|
81
|
+
"typecheck:py": "python3 -m mypy"
|
|
78
82
|
},
|
|
79
83
|
"files": [
|
|
80
84
|
"bin/",
|
package/scripts/check_split.py
CHANGED
|
@@ -54,6 +54,8 @@ from pathlib import Path
|
|
|
54
54
|
|
|
55
55
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
56
56
|
from _common import toolkit_dir as default_toolkit_dir
|
|
57
|
+
from frontmatter import FrontmatterError
|
|
58
|
+
from frontmatter import split_frontmatter as _split_frontmatter
|
|
57
59
|
|
|
58
60
|
# Sections that carry prescriptive process and environment-specific traps.
|
|
59
61
|
# They must load on every run, so they never move down into reference/.
|
|
@@ -75,16 +77,16 @@ NON_FILE_PREFIXES = ("#", "/", "mailto:", "tel:", "$")
|
|
|
75
77
|
# ---------------------------------------------------------------------------
|
|
76
78
|
|
|
77
79
|
def split_frontmatter(text: str) -> tuple[str, str]:
|
|
78
|
-
"""Return (frontmatter_block, body). Frontmatter is '' when absent.
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
"""Return (frontmatter_block, body). Frontmatter is '' when absent.
|
|
81
|
+
|
|
82
|
+
An unterminated block counts as body rather than being swallowed, so the
|
|
83
|
+
split gate still compares every byte of such a file.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
block, body = _split_frontmatter(text)
|
|
87
|
+
except FrontmatterError:
|
|
81
88
|
return "", text
|
|
82
|
-
|
|
83
|
-
if lines[idx].strip() == "---":
|
|
84
|
-
return "\n".join(lines[1:idx]), "\n".join(lines[idx + 1:])
|
|
85
|
-
# Unterminated frontmatter — treat the whole file as body rather than
|
|
86
|
-
# silently swallowing it.
|
|
87
|
-
return "", text
|
|
89
|
+
return block or "", body
|
|
88
90
|
|
|
89
91
|
|
|
90
92
|
def description_of(frontmatter: str) -> str:
|
package/scripts/claude_app.py
CHANGED
|
@@ -27,6 +27,9 @@ import tempfile
|
|
|
27
27
|
import zipfile
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
|
|
30
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
31
|
+
from frontmatter import split_frontmatter # noqa: E402
|
|
32
|
+
|
|
30
33
|
|
|
31
34
|
TOOLKIT_DIR = Path(__file__).resolve().parent.parent
|
|
32
35
|
APP_DIR = TOOLKIT_DIR / "app"
|
|
@@ -55,13 +58,8 @@ def _write_text(path: Path, content: str) -> None:
|
|
|
55
58
|
|
|
56
59
|
|
|
57
60
|
def _strip_frontmatter(content: str) -> str:
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return content.strip()
|
|
61
|
-
for index in range(1, len(lines)):
|
|
62
|
-
if lines[index].strip() == "---":
|
|
63
|
-
return "\n".join(lines[index + 1 :]).strip()
|
|
64
|
-
return content.strip()
|
|
61
|
+
"""Body of a rule file with surrounding whitespace removed."""
|
|
62
|
+
return split_frontmatter(content)[1].strip()
|
|
65
63
|
|
|
66
64
|
|
|
67
65
|
def render_plugin_hooks() -> str:
|
|
@@ -280,8 +280,10 @@ def _build_portable_skill_text(skill_file: Path, platform: str) -> str:
|
|
|
280
280
|
if invocation_lines:
|
|
281
281
|
rendered_frontmatter += "\n" + "\n".join(invocation_lines)
|
|
282
282
|
else:
|
|
283
|
-
frontmatter
|
|
284
|
-
|
|
283
|
+
# Native skills pass their frontmatter through untouched. The old
|
|
284
|
+
# line-by-line re-render flattened nested keys (`hooks:` blocks) into
|
|
285
|
+
# top-level `key: ` lines, which is not the document the author wrote.
|
|
286
|
+
rendered_frontmatter = match.group("frontmatter").strip("\n")
|
|
285
287
|
|
|
286
288
|
return f"---\n{rendered_frontmatter}\n---\n{body.rstrip()}\n"
|
|
287
289
|
|
|
@@ -1112,16 +1114,6 @@ def _is_relative_to(path: Path, parent: Path) -> bool:
|
|
|
1112
1114
|
return False
|
|
1113
1115
|
|
|
1114
1116
|
|
|
1115
|
-
def _parse_frontmatter(frontmatter_text: str) -> list[tuple[str, str]]:
|
|
1116
|
-
entries: list[tuple[str, str]] = []
|
|
1117
|
-
for line in frontmatter_text.splitlines():
|
|
1118
|
-
if ":" not in line:
|
|
1119
|
-
continue
|
|
1120
|
-
key, value = line.split(":", 1)
|
|
1121
|
-
entries.append((key.strip(), value.strip()))
|
|
1122
|
-
return entries
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
1117
|
def _invocation_metadata_lines(frontmatter_text: str) -> list[str]:
|
|
1126
1118
|
"""Return exact canonical invocation lines, rejecting duplicate fields."""
|
|
1127
1119
|
fields = ("user-invocable", "disable-model-invocation")
|
package/scripts/compile_slm.py
CHANGED
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 = [
|
|
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,
|
|
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]] = [
|