@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.
@@ -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 Clear all stats
9
- --json Output raw 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
- flag = sys.argv[1] if len(sys.argv) > 1 else ""
133
+ args = set(sys.argv[1:])
26
134
 
27
135
  # --reset
28
- if flag == "--reset":
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
- # --json
37
- if flag == "--json":
38
- if STATS_FILE.is_file():
39
- print(STATS_FILE.read_text(encoding="utf-8"), end="")
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
- print("{}")
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
- with open(STATS_FILE, encoding="utf-8") as f:
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:
@@ -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)