@softspark/ai-toolkit 3.0.0 → 3.0.2
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 +35 -0
- package/README.md +19 -5
- package/app/.claude-plugin/plugin.json +1 -1
- package/app/skills/hook-creator/SKILL.md +7 -3
- package/app/skills/introspect/SKILL.md +1 -1
- package/benchmarks/ecosystem-doctor-snapshot.json +13 -13
- package/bin/ai-toolkit.js +5 -1
- package/kb/procedures/release-preparation-sop.md +14 -5
- package/kb/procedures/release-verification-sop.md +112 -8
- package/kb/reference/cli-reference.md +14 -3
- package/kb/reference/competitive-features-implementation.md +9 -9
- package/kb/reference/hooks-catalog.md +8 -2
- package/kb/reference/supported-tools-registry.md +7 -7
- package/kb/reference/windows-support.md +50 -0
- package/llms-full.txt +220 -34
- package/llms.txt +1 -0
- package/manifest.json +1 -1
- package/package.json +6 -2
- package/scripts/_common.py +21 -0
- package/scripts/check_deps.py +14 -0
- package/scripts/stats.py +126 -39
- package/scripts/validate.py +160 -4
package/scripts/check_deps.py
CHANGED
|
@@ -37,6 +37,9 @@ REQUIRED = [
|
|
|
37
37
|
"pacman": "python3",
|
|
38
38
|
"apk": "python3",
|
|
39
39
|
"zypper": "python3",
|
|
40
|
+
"winget": "Python.Python.3",
|
|
41
|
+
"choco": "python",
|
|
42
|
+
"scoop": "python",
|
|
40
43
|
},
|
|
41
44
|
"reason": "All toolkit scripts run on Python 3 (stdlib only, no pip needed)",
|
|
42
45
|
},
|
|
@@ -50,6 +53,9 @@ REQUIRED = [
|
|
|
50
53
|
"pacman": "git",
|
|
51
54
|
"apk": "git",
|
|
52
55
|
"zypper": "git",
|
|
56
|
+
"winget": "Git.Git",
|
|
57
|
+
"choco": "git",
|
|
58
|
+
"scoop": "git",
|
|
53
59
|
},
|
|
54
60
|
"reason": "Version control — hooks, commits, PR workflows",
|
|
55
61
|
},
|
|
@@ -64,6 +70,9 @@ REQUIRED = [
|
|
|
64
70
|
"pacman": "nodejs",
|
|
65
71
|
"apk": "nodejs",
|
|
66
72
|
"zypper": "nodejs18",
|
|
73
|
+
"winget": "OpenJS.NodeJS",
|
|
74
|
+
"choco": "nodejs",
|
|
75
|
+
"scoop": "nodejs",
|
|
67
76
|
},
|
|
68
77
|
"reason": "CLI entry point (bin/ai-toolkit.js)",
|
|
69
78
|
},
|
|
@@ -80,6 +89,9 @@ OPTIONAL = [
|
|
|
80
89
|
"pacman": "sqlite",
|
|
81
90
|
"apk": "sqlite",
|
|
82
91
|
"zypper": "sqlite3",
|
|
92
|
+
"winget": "SQLite.SQLite",
|
|
93
|
+
"choco": "sqlite",
|
|
94
|
+
"scoop": "sqlite",
|
|
83
95
|
},
|
|
84
96
|
"reason": "Memory plugin pack (session persistence via SQLite + FTS5)",
|
|
85
97
|
},
|
|
@@ -93,6 +105,8 @@ OPTIONAL = [
|
|
|
93
105
|
"pacman": "bash-bats",
|
|
94
106
|
"apk": "bats",
|
|
95
107
|
"zypper": "bats",
|
|
108
|
+
"choco": "bats",
|
|
109
|
+
"scoop": "bats",
|
|
96
110
|
},
|
|
97
111
|
"reason": "Running toolkit test suite (npm test)",
|
|
98
112
|
},
|
package/scripts/stats.py
CHANGED
|
@@ -5,11 +5,13 @@ Reads ~/.softspark/ai-toolkit/stats.json (populated by track-usage.sh hook)
|
|
|
5
5
|
and displays a sorted table of skill invocations.
|
|
6
6
|
|
|
7
7
|
Options:
|
|
8
|
-
--reset
|
|
9
|
-
--json
|
|
8
|
+
--reset Clear all stats
|
|
9
|
+
--json Output raw JSON
|
|
10
|
+
--summary Output product telemetry summary
|
|
10
11
|
"""
|
|
11
12
|
from __future__ import annotations
|
|
12
13
|
|
|
14
|
+
from datetime import datetime, timedelta
|
|
13
15
|
import json
|
|
14
16
|
import sys
|
|
15
17
|
from pathlib import Path
|
|
@@ -18,14 +20,120 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
18
20
|
from paths import STATS_FILE as _STATS_FILE
|
|
19
21
|
|
|
20
22
|
STATS_FILE = _STATS_FILE
|
|
23
|
+
TOOLKIT_DIR = Path(__file__).resolve().parent.parent
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_stats() -> dict:
|
|
27
|
+
"""Load the stats file, returning an empty dict when absent."""
|
|
28
|
+
if not STATS_FILE.is_file():
|
|
29
|
+
return {}
|
|
30
|
+
with open(STATS_FILE, encoding="utf-8") as f:
|
|
31
|
+
return json.load(f)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _aggregate_rows(data: dict) -> list[tuple[str, dict]]:
|
|
35
|
+
"""Normalize supported stats formats into sorted (name, info) rows."""
|
|
36
|
+
if not data:
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
# Handle both formats: {skill: {count, last_used}} and {loop_runs: [...]}
|
|
40
|
+
if "loop_runs" in data:
|
|
41
|
+
agg: dict[str, dict] = {}
|
|
42
|
+
for run in data.get("loop_runs", []):
|
|
43
|
+
cmd = run.get("command", "unknown")
|
|
44
|
+
iters = run.get("iterations", [])
|
|
45
|
+
if cmd not in agg:
|
|
46
|
+
agg[cmd] = {"count": 0, "last_used": "unknown"}
|
|
47
|
+
agg[cmd]["count"] += len(iters) if iters else 1
|
|
48
|
+
started = run.get("started_at", "")
|
|
49
|
+
if started > agg[cmd]["last_used"]:
|
|
50
|
+
agg[cmd]["last_used"] = started
|
|
51
|
+
return sorted(agg.items(), key=lambda x: x[1].get("count", 0), reverse=True)
|
|
52
|
+
|
|
53
|
+
skill_data = {k: v for k, v in data.items() if isinstance(v, dict)}
|
|
54
|
+
return sorted(skill_data.items(), key=lambda x: x[1].get("count", 0), reverse=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _catalog_skill_names() -> set[str]:
|
|
58
|
+
"""Return skill directory names from the installed toolkit catalog."""
|
|
59
|
+
skills_dir = TOOLKIT_DIR / "app" / "skills"
|
|
60
|
+
if not skills_dir.is_dir():
|
|
61
|
+
return set()
|
|
62
|
+
return {
|
|
63
|
+
p.name
|
|
64
|
+
for p in skills_dir.iterdir()
|
|
65
|
+
if p.is_dir() and not p.name.startswith("_") and (p / "SKILL.md").is_file()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_datetime(value: str) -> datetime | None:
|
|
70
|
+
"""Parse stats timestamps emitted by toolkit hooks and loop runs."""
|
|
71
|
+
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ"):
|
|
72
|
+
try:
|
|
73
|
+
return datetime.strptime(value[:19] + ("Z" if fmt.endswith("Z") else ""), fmt)
|
|
74
|
+
except ValueError:
|
|
75
|
+
continue
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _build_summary(rows: list[tuple[str, dict]]) -> dict:
|
|
80
|
+
"""Build machine-readable product telemetry from aggregated rows."""
|
|
81
|
+
catalog = _catalog_skill_names()
|
|
82
|
+
used_names = {name for name, _ in rows}
|
|
83
|
+
used_catalog = used_names & catalog if catalog else used_names
|
|
84
|
+
total = sum(int(info.get("count", 0) or 0) for _, info in rows)
|
|
85
|
+
|
|
86
|
+
cutoff = datetime.now() - timedelta(days=7)
|
|
87
|
+
active_7d = 0
|
|
88
|
+
for _, info in rows:
|
|
89
|
+
last_used = _parse_datetime(str(info.get("last_used", "")))
|
|
90
|
+
if last_used and last_used >= cutoff:
|
|
91
|
+
active_7d += 1
|
|
92
|
+
|
|
93
|
+
catalog_total = len(catalog)
|
|
94
|
+
unused = max(catalog_total - len(used_catalog), 0) if catalog_total else 0
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
"totalInvocations": total,
|
|
98
|
+
"uniqueSkillsUsed": len(used_names),
|
|
99
|
+
"catalogSkillsTotal": catalog_total,
|
|
100
|
+
"unusedCatalogSkills": unused,
|
|
101
|
+
"catalogCoveragePct": round((len(used_catalog) / catalog_total) * 100, 1) if catalog_total else 0,
|
|
102
|
+
"activeSkills7d": active_7d,
|
|
103
|
+
"topSkills": [
|
|
104
|
+
{
|
|
105
|
+
"name": name,
|
|
106
|
+
"count": int(info.get("count", 0) or 0),
|
|
107
|
+
"lastUsed": info.get("last_used", "unknown"),
|
|
108
|
+
}
|
|
109
|
+
for name, info in rows[:5]
|
|
110
|
+
],
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _print_summary(summary: dict) -> None:
|
|
115
|
+
"""Print a compact human-readable product telemetry summary."""
|
|
116
|
+
print("Product Telemetry")
|
|
117
|
+
print("=================")
|
|
118
|
+
print(f"Total invocations: {summary['totalInvocations']}")
|
|
119
|
+
print(f"Unique skills used: {summary['uniqueSkillsUsed']}")
|
|
120
|
+
print(f"Catalog skills total: {summary['catalogSkillsTotal']}")
|
|
121
|
+
print(f"Unused catalog skills: {summary['unusedCatalogSkills']}")
|
|
122
|
+
print(f"Catalog coverage: {summary['catalogCoveragePct']}%")
|
|
123
|
+
print(f"Active skills in last 7 days: {summary['activeSkills7d']}")
|
|
124
|
+
if summary["topSkills"]:
|
|
125
|
+
print()
|
|
126
|
+
print("Top skills:")
|
|
127
|
+
for item in summary["topSkills"]:
|
|
128
|
+
print(f"- {item['name']}: {item['count']} ({item['lastUsed']})")
|
|
21
129
|
|
|
22
130
|
|
|
23
131
|
def main() -> None:
|
|
24
132
|
"""Display, export, or reset usage statistics."""
|
|
25
|
-
|
|
133
|
+
args = set(sys.argv[1:])
|
|
26
134
|
|
|
27
135
|
# --reset
|
|
28
|
-
if
|
|
136
|
+
if "--reset" in args:
|
|
29
137
|
if STATS_FILE.is_file():
|
|
30
138
|
STATS_FILE.unlink()
|
|
31
139
|
print("Stats reset.")
|
|
@@ -33,12 +141,20 @@ def main() -> None:
|
|
|
33
141
|
print("No stats file found.")
|
|
34
142
|
return
|
|
35
143
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
144
|
+
data = _load_stats()
|
|
145
|
+
rows = _aggregate_rows(data)
|
|
146
|
+
|
|
147
|
+
if "--summary" in args:
|
|
148
|
+
summary = _build_summary(rows)
|
|
149
|
+
if "--json" in args:
|
|
150
|
+
print(json.dumps(summary, indent=2))
|
|
40
151
|
else:
|
|
41
|
-
|
|
152
|
+
_print_summary(summary)
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
# --json
|
|
156
|
+
if "--json" in args:
|
|
157
|
+
print(STATS_FILE.read_text(encoding="utf-8") if STATS_FILE.is_file() else "{}")
|
|
42
158
|
return
|
|
43
159
|
|
|
44
160
|
# Default: pretty-print table
|
|
@@ -53,39 +169,10 @@ def main() -> None:
|
|
|
53
169
|
print("========================")
|
|
54
170
|
print()
|
|
55
171
|
|
|
56
|
-
|
|
57
|
-
data: dict = json.load(f)
|
|
58
|
-
|
|
59
|
-
if not data:
|
|
172
|
+
if not rows:
|
|
60
173
|
print("No invocations recorded.")
|
|
61
174
|
return
|
|
62
175
|
|
|
63
|
-
# Handle both formats: {skill: {count, last_used}} and {loop_runs: [...]}
|
|
64
|
-
if "loop_runs" in data:
|
|
65
|
-
runs = data["loop_runs"]
|
|
66
|
-
if not runs:
|
|
67
|
-
print("No invocations recorded.")
|
|
68
|
-
return
|
|
69
|
-
# Aggregate loop_runs by command
|
|
70
|
-
agg: dict[str, dict] = {}
|
|
71
|
-
for run in runs:
|
|
72
|
-
cmd = run.get("command", "unknown")
|
|
73
|
-
iters = run.get("iterations", [])
|
|
74
|
-
if cmd not in agg:
|
|
75
|
-
agg[cmd] = {"count": 0, "last_used": "unknown"}
|
|
76
|
-
agg[cmd]["count"] += len(iters) if iters else 1
|
|
77
|
-
started = run.get("started_at", "")
|
|
78
|
-
if started > agg[cmd]["last_used"]:
|
|
79
|
-
agg[cmd]["last_used"] = started
|
|
80
|
-
rows = sorted(agg.items(), key=lambda x: x[1]["count"], reverse=True)
|
|
81
|
-
else:
|
|
82
|
-
# Original format: {skill_name: {count, last_used}}
|
|
83
|
-
skill_data = {k: v for k, v in data.items() if isinstance(v, dict)}
|
|
84
|
-
if not skill_data:
|
|
85
|
-
print("No invocations recorded.")
|
|
86
|
-
return
|
|
87
|
-
rows = sorted(skill_data.items(), key=lambda x: x[1].get("count", 0), reverse=True)
|
|
88
|
-
|
|
89
176
|
print(f"{'Skill':<30} {'Count':>6} {'Last Used':<20}")
|
|
90
177
|
print("-" * 60)
|
|
91
178
|
for name, info in rows:
|
package/scripts/validate.py
CHANGED
|
@@ -42,9 +42,9 @@ VALID_HOOK_EVENTS = frozenset({
|
|
|
42
42
|
# Core lifecycle
|
|
43
43
|
"SessionStart", "SessionEnd", "UserPromptSubmit", "Notification",
|
|
44
44
|
# Tool lifecycle
|
|
45
|
-
"PreToolUse", "PostToolUse",
|
|
45
|
+
"PreToolUse", "PostToolUse", "PostToolUseFailure", "PostToolBatch",
|
|
46
46
|
# Turn lifecycle
|
|
47
|
-
"Stop", "StopFailure",
|
|
47
|
+
"Stop", "StopFailure", "UserPromptExpansion",
|
|
48
48
|
# Subagent lifecycle
|
|
49
49
|
"SubagentStart", "SubagentStop",
|
|
50
50
|
# Compaction
|
|
@@ -61,11 +61,67 @@ VALID_HOOK_EVENTS = frozenset({
|
|
|
61
61
|
"Setup", "InstructionsLoaded",
|
|
62
62
|
})
|
|
63
63
|
|
|
64
|
+
VALID_HOOK_TYPES = frozenset({
|
|
65
|
+
"command",
|
|
66
|
+
"http",
|
|
67
|
+
"prompt",
|
|
68
|
+
"agent",
|
|
69
|
+
"mcp_tool",
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
HOOK_TYPE_EVENTS = {
|
|
73
|
+
"agent": frozenset({"Stop", "SubagentStop"}),
|
|
74
|
+
"prompt": frozenset({
|
|
75
|
+
"PreToolUse",
|
|
76
|
+
"PostToolUse",
|
|
77
|
+
"PostToolUseFailure",
|
|
78
|
+
"PostToolBatch",
|
|
79
|
+
"UserPromptSubmit",
|
|
80
|
+
"UserPromptExpansion",
|
|
81
|
+
"Stop",
|
|
82
|
+
"SubagentStop",
|
|
83
|
+
}),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
HOOK_REQUIRED_FIELDS = {
|
|
87
|
+
"command": ("command",),
|
|
88
|
+
"http": ("url",),
|
|
89
|
+
"prompt": ("prompt",),
|
|
90
|
+
"agent": ("agent",),
|
|
91
|
+
"mcp_tool": ("server", "tool", "arguments"),
|
|
92
|
+
}
|
|
93
|
+
|
|
64
94
|
VALID_KB_CATEGORIES = frozenset({
|
|
65
95
|
"reference", "howto", "procedures", "troubleshooting", "best-practices",
|
|
66
96
|
"planning",
|
|
67
97
|
})
|
|
68
98
|
|
|
99
|
+
VALID_RULE_CATEGORIES = frozenset({
|
|
100
|
+
"coding-style",
|
|
101
|
+
"testing",
|
|
102
|
+
"security",
|
|
103
|
+
"performance",
|
|
104
|
+
"git-workflow",
|
|
105
|
+
"patterns",
|
|
106
|
+
"frameworks",
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
COMMON_RULE_CATEGORIES = frozenset({
|
|
110
|
+
"coding-style",
|
|
111
|
+
"testing",
|
|
112
|
+
"security",
|
|
113
|
+
"performance",
|
|
114
|
+
"git-workflow",
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
LANGUAGE_RULE_CATEGORIES = frozenset({
|
|
118
|
+
"coding-style",
|
|
119
|
+
"testing",
|
|
120
|
+
"security",
|
|
121
|
+
"patterns",
|
|
122
|
+
"frameworks",
|
|
123
|
+
})
|
|
124
|
+
|
|
69
125
|
PLANNED_ASSETS = [
|
|
70
126
|
"app/.claude-plugin/plugin.json",
|
|
71
127
|
"scripts/doctor.py",
|
|
@@ -354,8 +410,49 @@ def validate_legacy_commands(tk_dir: Path, vr: ValidationResult) -> None:
|
|
|
354
410
|
print()
|
|
355
411
|
|
|
356
412
|
|
|
413
|
+
def _validate_hook_handler(event: str, hook: dict, vr: ValidationResult) -> None:
|
|
414
|
+
"""Validate one hooks.json handler object."""
|
|
415
|
+
hook_type = hook.get("type")
|
|
416
|
+
if not hook_type:
|
|
417
|
+
vr.error(f"{event}: hook entry missing type")
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
if hook_type not in VALID_HOOK_TYPES:
|
|
421
|
+
vr.error(f"Unsupported hook handler type '{hook_type}' for event {event}")
|
|
422
|
+
return
|
|
423
|
+
|
|
424
|
+
allowed_events = HOOK_TYPE_EVENTS.get(hook_type)
|
|
425
|
+
if allowed_events is not None and event not in allowed_events:
|
|
426
|
+
vr.error(f"Hook type '{hook_type}' is not supported for event {event}")
|
|
427
|
+
|
|
428
|
+
for field in HOOK_REQUIRED_FIELDS.get(hook_type, ()):
|
|
429
|
+
if field not in hook:
|
|
430
|
+
vr.error(f"{event}: hook type '{hook_type}' missing required field '{field}'")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _validate_hook_entries(event: str, entries: object, vr: ValidationResult) -> None:
|
|
434
|
+
"""Validate hooks.json matcher entries for one event."""
|
|
435
|
+
if not isinstance(entries, list):
|
|
436
|
+
vr.error(f"{event}: expected list of hook matcher entries")
|
|
437
|
+
return
|
|
438
|
+
|
|
439
|
+
for index, entry in enumerate(entries):
|
|
440
|
+
if not isinstance(entry, dict):
|
|
441
|
+
vr.error(f"{event}[{index}]: expected hook matcher entry object")
|
|
442
|
+
continue
|
|
443
|
+
hooks = entry.get("hooks")
|
|
444
|
+
if not isinstance(hooks, list) or not hooks:
|
|
445
|
+
vr.error(f"{event}[{index}]: missing non-empty hooks list")
|
|
446
|
+
continue
|
|
447
|
+
for hook_index, hook in enumerate(hooks):
|
|
448
|
+
if not isinstance(hook, dict):
|
|
449
|
+
vr.error(f"{event}[{index}].hooks[{hook_index}]: expected hook object")
|
|
450
|
+
continue
|
|
451
|
+
_validate_hook_handler(event, hook, vr)
|
|
452
|
+
|
|
453
|
+
|
|
357
454
|
def validate_hook_events(tk_dir: Path, vr: ValidationResult) -> None:
|
|
358
|
-
"""Validate hook event names in hooks.json."""
|
|
455
|
+
"""Validate hook event names and handler shapes in hooks.json."""
|
|
359
456
|
print("## Hook Events")
|
|
360
457
|
hooks_file = tk_dir / "app" / "hooks.json"
|
|
361
458
|
|
|
@@ -373,9 +470,10 @@ def validate_hook_events(tk_dir: Path, vr: ValidationResult) -> None:
|
|
|
373
470
|
return
|
|
374
471
|
|
|
375
472
|
hooks = data.get("hooks", {})
|
|
376
|
-
for event in hooks:
|
|
473
|
+
for event, entries in hooks.items():
|
|
377
474
|
if event in VALID_HOOK_EVENTS:
|
|
378
475
|
print(f" OK: {event}")
|
|
476
|
+
_validate_hook_entries(event, entries, vr)
|
|
379
477
|
else:
|
|
380
478
|
vr.error(f"Unknown hook event: {event}")
|
|
381
479
|
|
|
@@ -386,6 +484,63 @@ def validate_hook_events(tk_dir: Path, vr: ValidationResult) -> None:
|
|
|
386
484
|
print()
|
|
387
485
|
|
|
388
486
|
|
|
487
|
+
def validate_language_rules(tk_dir: Path, vr: ValidationResult) -> None:
|
|
488
|
+
"""Validate structured language-rule directories under app/rules."""
|
|
489
|
+
print("## Language Rules")
|
|
490
|
+
rules_dir = tk_dir / "app" / "rules"
|
|
491
|
+
rule_count = 0
|
|
492
|
+
rule_errors = 0
|
|
493
|
+
|
|
494
|
+
if not rules_dir.is_dir():
|
|
495
|
+
vr.error("app/rules directory not found")
|
|
496
|
+
print()
|
|
497
|
+
return
|
|
498
|
+
|
|
499
|
+
for rule_dir in sorted(p for p in rules_dir.iterdir() if p.is_dir()):
|
|
500
|
+
language = rule_dir.name
|
|
501
|
+
expected = COMMON_RULE_CATEGORIES if language == "common" else LANGUAGE_RULE_CATEGORIES
|
|
502
|
+
seen: set[str] = set()
|
|
503
|
+
|
|
504
|
+
for rule_file in sorted(rule_dir.glob("*.md")):
|
|
505
|
+
rel = str(rule_file.relative_to(tk_dir))
|
|
506
|
+
rule_count += 1
|
|
507
|
+
if not _has_frontmatter(rule_file):
|
|
508
|
+
vr.error(f"{rel} - Missing YAML frontmatter")
|
|
509
|
+
rule_errors += 1
|
|
510
|
+
continue
|
|
511
|
+
|
|
512
|
+
fm_lines = _parse_frontmatter_lines(rule_file)
|
|
513
|
+
for field in ("language", "category", "version"):
|
|
514
|
+
if not _fm_has(fm_lines, field):
|
|
515
|
+
vr.error(f"{rel} - Missing required field: {field}")
|
|
516
|
+
rule_errors += 1
|
|
517
|
+
|
|
518
|
+
rule_language = _fm_field(fm_lines, "language").strip()
|
|
519
|
+
if rule_language and rule_language != language:
|
|
520
|
+
vr.error(f"{rel} language '{rule_language}' does not match directory '{language}'")
|
|
521
|
+
rule_errors += 1
|
|
522
|
+
|
|
523
|
+
category = _fm_field(fm_lines, "category").strip()
|
|
524
|
+
if category:
|
|
525
|
+
seen.add(category)
|
|
526
|
+
if category not in VALID_RULE_CATEGORIES:
|
|
527
|
+
vr.error(f"{rel} has invalid rule category '{category}'")
|
|
528
|
+
rule_errors += 1
|
|
529
|
+
if rule_file.stem != category:
|
|
530
|
+
vr.error(f"{rel} filename does not match category '{category}'")
|
|
531
|
+
rule_errors += 1
|
|
532
|
+
|
|
533
|
+
for category in sorted(expected - seen):
|
|
534
|
+
vr.error(f"app/rules/{language} missing required rule category: {category}")
|
|
535
|
+
rule_errors += 1
|
|
536
|
+
|
|
537
|
+
if rule_errors == 0:
|
|
538
|
+
print(f" OK: {rule_count} rule files validated")
|
|
539
|
+
else:
|
|
540
|
+
print(f" Found: {rule_count} rule files ({rule_errors} with errors)")
|
|
541
|
+
print()
|
|
542
|
+
|
|
543
|
+
|
|
389
544
|
def validate_planned_assets(tk_dir: Path, vr: ValidationResult) -> None:
|
|
390
545
|
"""Validate that planned assets exist and are non-empty."""
|
|
391
546
|
print("## Planned Assets")
|
|
@@ -781,6 +936,7 @@ def _run_all_checks(tk_dir: Path, vr: ValidationResult) -> tuple[int, int, str]:
|
|
|
781
936
|
skill_count = validate_skills(tk_dir, vr)
|
|
782
937
|
validate_legacy_commands(tk_dir, vr)
|
|
783
938
|
validate_hook_events(tk_dir, vr)
|
|
939
|
+
validate_language_rules(tk_dir, vr)
|
|
784
940
|
validate_planned_assets(tk_dir, vr)
|
|
785
941
|
validate_plugin_packs(tk_dir, vr)
|
|
786
942
|
validate_kb_documents(tk_dir, vr)
|