@softspark/ai-toolkit 4.31.0 → 4.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +26 -19
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/claude-app/hooks/hooks.json +4 -2
  5. package/app/claude-app/skills/ai-toolkit-rules/SKILL.md +30 -16
  6. package/app/hooks/quality-gate.sh +9 -2
  7. package/app/hooks.json +4 -2
  8. package/app/rules/common/git-team.md +33 -0
  9. package/app/rules/common/git-workflow.md +6 -20
  10. package/app/rules/common/performance.md +25 -1
  11. package/app/rules/common/testing.md +7 -1
  12. package/benchmarks/ecosystem-doctor-snapshot.json +17 -15
  13. package/bin/ai-toolkit.js +2 -0
  14. package/kb/procedures/sop-maintenance.md +6 -3
  15. package/kb/reference/cli-reference.md +3 -2
  16. package/kb/reference/global-install-model.md +16 -3
  17. package/kb/reference/hooks-catalog.md +5 -3
  18. package/kb/reference/language-rules.md +28 -10
  19. package/kb/reference/unique-features.md +2 -1
  20. package/llms-full.txt +60 -22
  21. package/manifest.json +2 -2
  22. package/package.json +5 -2
  23. package/scripts/benchmark_ecosystem.py +0 -1
  24. package/scripts/check_split.py +11 -9
  25. package/scripts/claude_app.py +5 -7
  26. package/scripts/codex_skill_adapter.py +4 -12
  27. package/scripts/compile_slm.py +10 -26
  28. package/scripts/doctor.py +322 -0
  29. package/scripts/evaluate_skills.py +1 -1
  30. package/scripts/frontmatter.py +452 -29
  31. package/scripts/generate_augment_rules.py +4 -4
  32. package/scripts/generate_cursor_mdc.py +2 -3
  33. package/scripts/generate_language_rules_skills.py +8 -14
  34. package/scripts/generate_llms_txt.py +1 -15
  35. package/scripts/generate_opencode_agents.py +0 -1
  36. package/scripts/generate_opencode_skills.py +2 -20
  37. package/scripts/generate_windsurf_rules.py +0 -1
  38. package/scripts/generator_base.py +0 -1
  39. package/scripts/inject_hook_cli.py +15 -2
  40. package/scripts/inject_mcp_cli.py +1 -2
  41. package/scripts/install.py +32 -1
  42. package/scripts/install_git_hooks.py +0 -1
  43. package/scripts/install_steps/ai_tools.py +65 -25
  44. package/scripts/install_steps/markers.py +6 -6
  45. package/scripts/install_steps/skill_scope.py +188 -0
  46. package/scripts/instruction_core.py +5 -8
  47. package/scripts/merge-hooks.py +13 -3
  48. package/scripts/pack_codebase.py +1 -1
  49. package/scripts/surface_manifest.py +6 -7
  50. package/scripts/validate.py +180 -11
@@ -21,7 +21,7 @@ from pathlib import Path
21
21
  sys.path.insert(0, str(Path(__file__).resolve().parent))
22
22
  from codex_skill_adapter import build_opencode_skill_text
23
23
  from emission import skills_dir
24
- from frontmatter import frontmatter_field
24
+ from frontmatter import frontmatter_block, frontmatter_field, frontmatter_sections
25
25
  from secure_fs import SecureDestination, SecureTransaction, nearest_existing_root
26
26
 
27
27
 
@@ -43,24 +43,6 @@ class PreparedSkill:
43
43
  return {Path("SKILL.md"), Path(MANAGED_MANIFEST), *self.files}
44
44
 
45
45
 
46
- def _frontmatter_sections(skill_file: Path) -> dict[str, list[str]]:
47
- text = skill_file.read_text(encoding="utf-8")
48
- if not text.startswith("---\n"):
49
- return {}
50
- parts = text.split("---", 2)
51
- if len(parts) != 3:
52
- return {}
53
- sections: dict[str, list[str]] = {}
54
- current: str | None = None
55
- for line in parts[1].strip("\n").splitlines():
56
- if line and not line[0].isspace() and ":" in line:
57
- current = line.split(":", 1)[0].strip()
58
- sections[current] = [line]
59
- elif current is not None:
60
- sections[current].append(line)
61
- return sections
62
-
63
-
64
46
  def _portable_body(skill_file: Path) -> str:
65
47
  rendered = build_opencode_skill_text(skill_file)
66
48
  if not rendered.startswith("---\n"):
@@ -72,7 +54,7 @@ def _portable_body(skill_file: Path) -> str:
72
54
  def _render_skill(skill_file: Path) -> str:
73
55
  name = frontmatter_field(skill_file, "name")
74
56
  description = frontmatter_field(skill_file, "description")
75
- sections = _frontmatter_sections(skill_file)
57
+ sections = frontmatter_sections(frontmatter_block(skill_file))
76
58
  lines = [
77
59
  "---",
78
60
  f"name: {name}",
@@ -50,7 +50,6 @@ from dir_rules_shared import (
50
50
  build_language_rules,
51
51
  build_registered_rules,
52
52
  cleanup_stale,
53
- rule_testing,
54
53
  write_rules,
55
54
  )
56
55
 
@@ -32,7 +32,6 @@ from __future__ import annotations
32
32
 
33
33
  import os
34
34
  import sys
35
- from pathlib import Path
36
35
 
37
36
  from paths import RULES_DIR
38
37
 
@@ -221,8 +221,17 @@ def _entry_source(entry: dict) -> str | None:
221
221
  return None
222
222
 
223
223
 
224
+ # Handler fields that change *how* a command hook is scheduled, not *what* it
225
+ # runs. A legacy untagged entry that differs only in these is the same hook,
226
+ # otherwise adding `async` or `timeout` to app/hooks.json would leave every
227
+ # existing install running the old copy and the new copy side by side.
228
+ _SCHEDULING_FIELDS = frozenset({
229
+ "async", "asyncRewake", "timeout", "statusMessage", "shell", "once", "if",
230
+ })
231
+
232
+
224
233
  def _entry_signature(entry: dict) -> tuple:
225
- """Return behavior-defining hook fields without source tags."""
234
+ """Return behavior-defining hook fields without source or scheduling tags."""
226
235
  handlers = []
227
236
  for hook in entry.get("hooks", []):
228
237
  if not isinstance(hook, dict):
@@ -230,7 +239,11 @@ def _entry_signature(entry: dict) -> tuple:
230
239
  continue
231
240
  handlers.append(
232
241
  tuple(
233
- sorted((key, value) for key, value in hook.items() if key != "_source")
242
+ sorted(
243
+ (key, value)
244
+ for key, value in hook.items()
245
+ if key != "_source" and key not in _SCHEDULING_FIELDS
246
+ )
234
247
  )
235
248
  )
236
249
  return (entry.get("matcher", ""), tuple(handlers))
@@ -55,7 +55,6 @@ from __future__ import annotations
55
55
 
56
56
  import copy
57
57
  import json
58
- import os
59
58
  import re
60
59
  import sys
61
60
  import urllib.parse
@@ -163,7 +162,7 @@ def _check_collisions(
163
162
 
164
163
  if not force:
165
164
  print(
166
- f"Error: server name collision(s) in .mcp.json (re-run with --force):",
165
+ "Error: server name collision(s) in .mcp.json (re-run with --force):",
167
166
  file=sys.stderr,
168
167
  )
169
168
  for name, other_source in collisions:
@@ -41,6 +41,9 @@ Options:
41
41
  --persona <p> backend-lead|frontend-lead|devops-eng|junior-dev
42
42
  --modules <list> Install specific modules (comma-separated)
43
43
  --auto-detect Detect project languages and install matching rules
44
+ --language-skills <s> detected (default): turn off <lang>-rules/<lang>-patterns
45
+ skills for languages no registered project uses;
46
+ all: keep every language skill on (persisted)
44
47
  --status Show installed modules and exit
45
48
  """
46
49
  from __future__ import annotations
@@ -50,7 +53,7 @@ import sys
50
53
  from pathlib import Path
51
54
 
52
55
  sys.path.insert(0, str(Path(__file__).resolve().parent))
53
- from _common import toolkit_dir, app_dir, inject_rule
56
+ from _common import toolkit_dir, app_dir, inject_rule, should_install
54
57
  from emission import agent_count as count_agents, skill_count as count_skills
55
58
 
56
59
  # Step modules
@@ -223,6 +226,7 @@ def parse_args(argv: list[str]) -> dict:
223
226
  "refresh_base": False,
224
227
  "skip_register": False,
225
228
  "codex_skills": False,
229
+ "language_skills": "",
226
230
  }
227
231
  i = 0
228
232
  while i < len(argv):
@@ -283,6 +287,11 @@ def parse_args(argv: list[str]) -> dict:
283
287
  cfg["skip_register"] = True
284
288
  elif arg == "--codex-skills":
285
289
  cfg["codex_skills"] = True
290
+ elif arg.startswith("--language-skills="):
291
+ cfg["language_skills"] = arg.split("=", 1)[1]
292
+ elif arg == "--language-skills":
293
+ i += 1
294
+ cfg["language_skills"] = argv[i] if i < len(argv) else ""
286
295
  elif arg.startswith("-"):
287
296
  print(f"Unknown option: {arg}")
288
297
  sys.exit(1)
@@ -335,6 +344,15 @@ def validate_args(cfg: dict) -> None:
335
344
  if c and c not in VALID_COMPONENTS:
336
345
  errors.append(f"Unknown component in --skip: '{c}' (valid: {', '.join(sorted(VALID_COMPONENTS))})")
337
346
 
347
+ # Validate --language-skills
348
+ if cfg["language_skills"]:
349
+ from install_steps.skill_scope import VALID_SCOPES
350
+ if cfg["language_skills"] not in VALID_SCOPES:
351
+ errors.append(
352
+ f"Unknown --language-skills value: '{cfg['language_skills']}' "
353
+ f"(valid: {', '.join(VALID_SCOPES)})"
354
+ )
355
+
338
356
  # Validate --editors
339
357
  if cfg["editors"] and cfg["editors"] != "all":
340
358
  for e in cfg["editors"].split(","):
@@ -848,6 +866,19 @@ def main() -> None:
848
866
  if is_new:
849
867
  print(f" Registered project in {TOOLKIT_DATA_DIR / 'projects.json'}")
850
868
 
869
+ # Language knowledge skills are symlinked for every language, but their
870
+ # descriptions load into every session. Scope them to the languages the
871
+ # registered projects actually use (after registration, so a new project's
872
+ # language re-enables its skills in the same run). Read-only in dry-run.
873
+ if should_install("skills", only, skip):
874
+ from install_steps.skill_scope import reconcile_language_skill_overrides, resolve_scope
875
+ reconcile_language_skill_overrides(
876
+ toolkit_dir,
877
+ target_dir / ".claude" / "settings.json",
878
+ scope=resolve_scope(cfg["language_skills"]),
879
+ dry_run=dry_run,
880
+ )
881
+
851
882
  # A global install is authoritative for Claude Code, so an uploaded Claude
852
883
  # app plugin must not feed it in parallel. Uploading the ZIP re-enables the
853
884
  # plugin every time, so this is re-asserted on every install/update rather
@@ -11,7 +11,6 @@ Usage: python3 install_git_hooks.py [target-dir]
11
11
  """
12
12
  from __future__ import annotations
13
13
 
14
- import os
15
14
  import sys
16
15
  from pathlib import Path
17
16
 
@@ -12,6 +12,7 @@ import subprocess
12
12
  from pathlib import Path
13
13
 
14
14
  from _common import app_dir, inject_section, toolkit_dir
15
+ from frontmatter import FrontmatterError, parse_frontmatter, split_frontmatter
15
16
  from codex_skill_adapter import (
16
17
  cleanup_codex_skills,
17
18
  managed_skill_surface_transaction,
@@ -965,7 +966,7 @@ def install_local_project(rules_dir: Path, dry_run: bool, reset: bool,
965
966
  _apply_extends_config(cwd, merged_config)
966
967
 
967
968
  # Inject language-specific rules into project CLAUDE.md
968
- _inject_language_rules(cwd, language_modules)
969
+ _inject_language_rules(cwd, language_modules, profile=profile)
969
970
 
970
971
  # Install editor configs only for resolved editors
971
972
  _create_local_ai_tool_configs(cwd, rules_dir, resolved_editors,
@@ -1061,17 +1062,21 @@ def _apply_extends_config(cwd: Path, merged: dict) -> None:
1061
1062
  print(" Saved: .softspark-toolkit-extends.json (resolution metadata)")
1062
1063
 
1063
1064
 
1064
- def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> None:
1065
+ def _inject_language_rules(cwd: Path, language_modules: list[str] | None,
1066
+ profile: str = "standard") -> None:
1065
1067
  """Install Claude language-rule entrypoints for a project.
1066
1068
 
1067
1069
  Per-language rules (``app/rules/<lang>/``) are NOT injected here -- they
1068
1070
  ship as ``<lang>-rules`` knowledge skills under ``app/skills/`` and load
1069
1071
  contextually via the Agent Skills progressive-disclosure mechanism.
1070
1072
 
1071
- Common rules are written as Claude Code path-scoped rules under
1072
- ``.claude/rules/``. Current Claude Code guidance targets under 200 lines
1073
- per ``CLAUDE.md`` file; path-scoped rules keep startup context smaller
1074
- while still loading the rule bodies when project files are opened.
1073
+ Common rules are written as Claude Code rules under ``.claude/rules/``.
1074
+ Each source rule's ``paths`` frontmatter decides whether it is always-on
1075
+ (``**/*``: coding-style, git-workflow, security) or path-scoped (testing,
1076
+ performance load only for matching files). Current Claude Code guidance
1077
+ targets under 200 lines per ``CLAUDE.md`` file; keeping rule bodies out
1078
+ of it and scoping the ones that are file-type specific keeps startup
1079
+ context smaller.
1075
1080
  """
1076
1081
  if not language_modules:
1077
1082
  return
@@ -1081,7 +1086,7 @@ def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> Non
1081
1086
  if not common_dir.is_dir():
1082
1087
  return
1083
1088
 
1084
- rule_files = _sync_claude_common_rules(cwd, common_dir)
1089
+ rule_files = _sync_claude_common_rules(cwd, common_dir, profile)
1085
1090
 
1086
1091
  # Detect requested per-language modules so we can name the linked skills
1087
1092
  # in the marker block. The modules themselves are not inlined.
@@ -1095,12 +1100,18 @@ def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> Non
1095
1100
  lines: list[str] = ["# Language Rules", ""]
1096
1101
  lines.append(
1097
1102
  "Common ai-toolkit rules live in `.claude/rules/ai-toolkit-*.md` "
1098
- "with Claude Code `paths` frontmatter so they load when project files "
1099
- "are opened instead of expanding this CLAUDE.md at session startup."
1103
+ "with Claude Code `paths` frontmatter instead of expanding this "
1104
+ "CLAUDE.md. Always-on rules load in every session; path-scoped rules "
1105
+ "load only when a matching file is touched."
1100
1106
  )
1101
- if rule_files:
1107
+ always_on = [p for p, on in rule_files if on]
1108
+ scoped = [p for p, on in rule_files if not on]
1109
+ if always_on:
1102
1110
  lines.append("")
1103
- lines.append("Common rule files: " + ", ".join(f"`{p}`" for p in rule_files) + ".")
1111
+ lines.append("Always-on: " + ", ".join(f"`{p}`" for p in always_on) + ".")
1112
+ if scoped:
1113
+ lines.append("")
1114
+ lines.append("Path-scoped: " + ", ".join(f"`{p}`" for p in scoped) + ".")
1104
1115
  lines.append("")
1105
1116
  lines.append(
1106
1117
  "Language-specific rules live in `<lang>-rules` knowledge skills "
@@ -1132,39 +1143,68 @@ def _inject_language_rules(cwd: Path, language_modules: list[str] | None) -> Non
1132
1143
  tmp_path.unlink(missing_ok=True)
1133
1144
 
1134
1145
 
1135
- def _strip_rule_frontmatter(text: str) -> str:
1136
- if text.startswith("---"):
1137
- end = text.find("\n---", 3)
1138
- if end != -1:
1139
- return text[end + 4:].lstrip("\n")
1140
- return text
1146
+ ALWAYS_ON_RULE_PATHS = ["**/*"]
1147
+
1141
1148
 
1149
+ def _rule_source(src: Path) -> tuple[str, list[str], list[str]]:
1150
+ """Return ``(body, paths, profiles)`` for one ``app/rules/common`` file.
1142
1151
 
1143
- def _sync_claude_common_rules(cwd: Path, common_dir: Path) -> list[str]:
1152
+ ``paths`` defaults to always-on (``**/*``); an empty ``profiles`` means
1153
+ the rule ships in every profile. Shipped rules pass ``validate.py`` before
1154
+ release, so a parse failure here is a broken install, not a soft case.
1155
+ """
1156
+ text = src.read_text(encoding="utf-8")
1157
+ try:
1158
+ _, body = split_frontmatter(text)
1159
+ meta = parse_frontmatter(text)
1160
+ except FrontmatterError as error:
1161
+ raise RuntimeError(f"invalid frontmatter in {src}: {error}") from error
1162
+ paths = meta.get("paths")
1163
+ profiles = meta.get("profiles")
1164
+ return (
1165
+ body,
1166
+ [str(p) for p in paths] if isinstance(paths, list) and paths else list(ALWAYS_ON_RULE_PATHS),
1167
+ [str(p) for p in profiles] if isinstance(profiles, list) else [],
1168
+ )
1169
+
1170
+
1171
+ def _sync_claude_common_rules(cwd: Path, common_dir: Path,
1172
+ profile: str = "standard") -> list[tuple[str, bool]]:
1144
1173
  """Write common ai-toolkit rules as Claude Code path-scoped rules.
1145
1174
 
1175
+ Each source rule's ``paths`` frontmatter decides its scope; rules
1176
+ without one are always-on. A ``profiles`` frontmatter restricts the rule
1177
+ to those install profiles (``git-team`` ships with ``strict`` only); a
1178
+ managed file whose rule no longer applies is removed, so switching profile
1179
+ on a rerun converges. Returns ``(relative path, always_on)`` pairs.
1180
+
1146
1181
  Only ``ai-toolkit-*.md`` files are managed. User-authored files in
1147
1182
  ``.claude/rules/`` are preserved.
1148
1183
  """
1149
1184
  rules_dir = cwd / ".claude" / "rules"
1150
1185
  rules_dir.mkdir(parents=True, exist_ok=True)
1151
1186
 
1152
- source_files = sorted(common_dir.glob("*.md"))
1153
- expected = {f"ai-toolkit-{src.stem}.md" for src in source_files}
1187
+ source_files: list[tuple[Path, str, list[str]]] = []
1188
+ for src in sorted(common_dir.glob("*.md")):
1189
+ body, paths, gate = _rule_source(src)
1190
+ if gate and profile not in gate:
1191
+ continue
1192
+ source_files.append((src, body, paths))
1193
+ expected = {f"ai-toolkit-{src.stem}.md" for src, _, _ in source_files}
1154
1194
  for stale in sorted(rules_dir.glob("ai-toolkit-*.md")):
1155
1195
  if stale.name not in expected:
1156
1196
  stale.unlink()
1157
1197
 
1158
- written: list[str] = []
1159
- for src in source_files:
1160
- body = _strip_rule_frontmatter(src.read_text(encoding="utf-8")).rstrip()
1198
+ written: list[tuple[str, bool]] = []
1199
+ for src, body, paths in source_files:
1200
+ body = body.lstrip("\n").rstrip()
1161
1201
  rel = Path(".claude") / "rules" / f"ai-toolkit-{src.stem}.md"
1162
1202
  target = cwd / rel
1163
1203
  target.write_text(
1164
1204
  "\n".join([
1165
1205
  "---",
1166
1206
  "paths:",
1167
- ' - "**/*"',
1207
+ *[f' - "{p}"' for p in paths],
1168
1208
  "---",
1169
1209
  "",
1170
1210
  body,
@@ -1172,7 +1212,7 @@ def _sync_claude_common_rules(cwd: Path, common_dir: Path) -> list[str]:
1172
1212
  ]),
1173
1213
  encoding="utf-8",
1174
1214
  )
1175
- written.append(rel.as_posix())
1215
+ written.append((rel.as_posix(), paths == ALWAYS_ON_RULE_PATHS))
1176
1216
 
1177
1217
  return written
1178
1218
 
@@ -214,10 +214,10 @@ def _refresh_url_rules(rules_dir: Path) -> None:
214
214
  except Exception as exc:
215
215
  if rule_file.is_file():
216
216
  print(f" Warning: could not refresh '{rule_name}' from {url}: {exc}")
217
- print(f" Using cached version.")
217
+ print(" Using cached version.")
218
218
  else:
219
219
  print(f" Warning: could not fetch '{rule_name}' from {url}: {exc}")
220
- print(f" No cached version — rule will be skipped.")
220
+ print(" No cached version — rule will be skipped.")
221
221
 
222
222
 
223
223
  def refresh_url_hooks(target_dir: str | None = None) -> None:
@@ -250,10 +250,10 @@ def refresh_url_hooks(target_dir: str | None = None) -> None:
250
250
  except Exception as exc:
251
251
  if cached_file.is_file():
252
252
  print(f" Warning: could not refresh '{hook_name}' from {url}: {exc}")
253
- print(f" Using cached version.")
253
+ print(" Using cached version.")
254
254
  else:
255
255
  print(f" Warning: could not fetch '{hook_name}' from {url}: {exc}")
256
- print(f" No cached version — hook will be skipped.")
256
+ print(" No cached version — hook will be skipped.")
257
257
  continue
258
258
 
259
259
  # Re-inject from cached file
@@ -291,10 +291,10 @@ def refresh_url_mcp(target_dir: str | None = None) -> None:
291
291
  except Exception as exc:
292
292
  if cached_file.is_file():
293
293
  print(f" Warning: could not refresh '{template_name}' from {url}: {exc}")
294
- print(f" Using cached version.")
294
+ print(" Using cached version.")
295
295
  else:
296
296
  print(f" Warning: could not fetch '{template_name}' from {url}: {exc}")
297
- print(f" No cached version — template will be skipped.")
297
+ print(" No cached version — template will be skipped.")
298
298
  continue
299
299
 
300
300
  if cached_file.is_file():
@@ -0,0 +1,188 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2024-2026 Lukasz Krzemien (biuro@softspark.eu)
3
+ # Source: https://github.com/softspark/ai-toolkit
4
+
5
+ """Scope language knowledge skills to the stacks a user actually works in.
6
+
7
+ The global install symlinks every ``<lang>-rules`` / ``<lang>-patterns``
8
+ skill into ``~/.claude/skills``. Each one's description then sits in the
9
+ model-visible skill listing of every session, whether or not the user has a
10
+ single project in that language. This step reads the languages detected
11
+ across the registered projects (``projects.json``) and turns the others off
12
+ through Claude Code's ``skillOverrides`` setting.
13
+
14
+ Rules of engagement:
15
+
16
+ * Evidence first. With no registered project on disk there is nothing to
17
+ judge, so nothing is disabled.
18
+ * Only entries this step wrote are ever removed again. They are tracked in
19
+ ``state.json`` under ``managed_skill_overrides``; a user's own override is
20
+ left alone even when it names a language skill.
21
+ * ``--language-skills all`` restores every managed entry and persists the
22
+ choice so later ``install`` / ``update`` runs do not prune again.
23
+ * Reversible by hand: delete the key from ``~/.claude/settings.json``.
24
+
25
+ Stdlib-only.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from install_steps.detect_language import detect_languages
34
+ from install_steps.install_state import load_state, save_state
35
+ from install_steps.project_registry import load_registry
36
+
37
+ LANGUAGE_SKILL_SUFFIXES = ("rules", "patterns")
38
+ SCOPE_ALL = "all"
39
+ SCOPE_DETECTED = "detected"
40
+ VALID_SCOPES = (SCOPE_ALL, SCOPE_DETECTED)
41
+ STATE_SCOPE_KEY = "language_skill_scope"
42
+ STATE_MANAGED_KEY = "managed_skill_overrides"
43
+
44
+
45
+ def language_skill_names(toolkit_dir: Path) -> dict[str, str]:
46
+ """Map each shipped language knowledge skill to its language.
47
+
48
+ A skill counts as a language skill when it is named ``<lang>-rules`` or
49
+ ``<lang>-patterns`` and ``app/rules/<lang>/`` exists. ``flutter-patterns``
50
+ therefore does not (no ``app/rules/flutter``), which is deliberate:
51
+ only skills generated from a language rule set are scoped.
52
+ """
53
+ rules_root = toolkit_dir / "app" / "rules"
54
+ skills_root = toolkit_dir / "app" / "skills"
55
+ if not rules_root.is_dir() or not skills_root.is_dir():
56
+ return {}
57
+ languages = {p.name for p in rules_root.iterdir() if p.is_dir() and p.name != "common"}
58
+ mapping: dict[str, str] = {}
59
+ for skill_dir in sorted(skills_root.iterdir()):
60
+ if not skill_dir.is_dir() or "-" not in skill_dir.name:
61
+ continue
62
+ lang, _, suffix = skill_dir.name.rpartition("-")
63
+ if suffix in LANGUAGE_SKILL_SUFFIXES and lang in languages:
64
+ mapping[skill_dir.name] = lang
65
+ return mapping
66
+
67
+
68
+ def detected_languages_from_registry(toolkit_dir: Path) -> set[str] | None:
69
+ """Union of languages detected across registered projects that still exist.
70
+
71
+ Returns ``None`` when no registered project directory exists, which
72
+ callers must read as "no evidence", not "no languages".
73
+ """
74
+ found: set[str] = set()
75
+ seen_project = False
76
+ for entry in load_registry():
77
+ path = entry.get("path")
78
+ if not isinstance(path, str):
79
+ continue
80
+ project_dir = Path(path)
81
+ if not project_dir.is_dir():
82
+ continue
83
+ seen_project = True
84
+ for module in detect_languages(project_dir, toolkit_dir):
85
+ if module.startswith("rules-") and module != "rules-common":
86
+ found.add(module[len("rules-"):])
87
+ return found if seen_project else None
88
+
89
+
90
+ def _load_settings(settings_path: Path) -> dict[str, Any]:
91
+ if not settings_path.is_file():
92
+ return {}
93
+ try:
94
+ with open(settings_path, encoding="utf-8") as f:
95
+ data = json.load(f)
96
+ except (OSError, json.JSONDecodeError):
97
+ return {}
98
+ return data if isinstance(data, dict) else {}
99
+
100
+
101
+ def _save_settings(settings_path: Path, data: dict[str, Any]) -> None:
102
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
103
+ with open(settings_path, "w", encoding="utf-8") as f:
104
+ json.dump(data, f, indent=4)
105
+ f.write("\n")
106
+
107
+
108
+ def resolve_scope(requested: str, state: dict[str, Any] | None = None) -> str:
109
+ """Pick the effective scope: explicit flag wins, else the persisted choice."""
110
+ if requested in VALID_SCOPES:
111
+ return requested
112
+ persisted = (state if state is not None else load_state()).get(STATE_SCOPE_KEY)
113
+ return persisted if persisted in VALID_SCOPES else SCOPE_DETECTED
114
+
115
+
116
+ def reconcile_language_skill_overrides(
117
+ toolkit_dir: Path,
118
+ settings_path: Path,
119
+ *,
120
+ scope: str,
121
+ dry_run: bool = False,
122
+ ) -> tuple[list[str], list[str]]:
123
+ """Bring ``skillOverrides`` in line with the detected language set.
124
+
125
+ Returns ``(disabled, restored)`` skill names. Prints one line per
126
+ outcome in the install's `` Verb: detail`` style.
127
+ """
128
+ state = load_state()
129
+ managed: set[str] = {
130
+ name for name in state.get(STATE_MANAGED_KEY, []) if isinstance(name, str)
131
+ }
132
+ skills = language_skill_names(toolkit_dir)
133
+ settings = _load_settings(settings_path)
134
+ overrides = settings.get("skillOverrides")
135
+ if not isinstance(overrides, dict):
136
+ overrides = {}
137
+
138
+ disabled: list[str] = []
139
+ restored: list[str] = []
140
+
141
+ if scope == SCOPE_ALL:
142
+ languages: set[str] | None = None
143
+ else:
144
+ languages = detected_languages_from_registry(toolkit_dir)
145
+
146
+ for name, lang in sorted(skills.items()):
147
+ keep_on = languages is None or lang in languages
148
+ if keep_on:
149
+ if name in managed and overrides.get(name) == "off":
150
+ overrides.pop(name)
151
+ restored.append(name)
152
+ managed.discard(name)
153
+ continue
154
+ if name in overrides:
155
+ # The user (or an earlier run) already has an opinion; a run never
156
+ # adopts an entry it did not write.
157
+ continue
158
+ overrides[name] = "off"
159
+ managed.add(name)
160
+ disabled.append(name)
161
+
162
+ if dry_run:
163
+ if languages is None and scope != SCOPE_ALL:
164
+ print(" Would skip: language skill scoping (no registered project on disk)")
165
+ for name in disabled:
166
+ print(f" Would disable: skill {name} (skillOverrides)")
167
+ for name in restored:
168
+ print(f" Would restore: skill {name} (skillOverrides)")
169
+ return disabled, restored
170
+
171
+ if disabled or restored:
172
+ if overrides:
173
+ settings["skillOverrides"] = overrides
174
+ else:
175
+ settings.pop("skillOverrides", None)
176
+ _save_settings(settings_path, settings)
177
+ save_state({STATE_SCOPE_KEY: scope, STATE_MANAGED_KEY: sorted(managed)})
178
+
179
+ if languages is None and scope != SCOPE_ALL:
180
+ print(" Skipped: language skill scoping (no registered project on disk; run install --local in a project first)")
181
+ elif disabled:
182
+ print(f" Disabled: {len(disabled)} language skill(s) outside your detected stacks "
183
+ f"({', '.join(disabled)})")
184
+ if restored:
185
+ print(f" Restored: {len(restored)} language skill(s) ({', '.join(restored)})")
186
+ if languages is not None and not disabled and not restored and scope == SCOPE_DETECTED:
187
+ print(f" Language skills: scoped to {', '.join(sorted(languages)) or 'no detected languages'} (no change)")
188
+ return disabled, restored
@@ -6,8 +6,12 @@
6
6
  from __future__ import annotations
7
7
 
8
8
  import re
9
+ import sys
9
10
  from pathlib import Path
10
11
 
12
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
13
+ from frontmatter import split_frontmatter # noqa: E402
14
+
11
15
 
12
16
  CONSTITUTION_PATH = (
13
17
  Path(__file__).resolve().parent.parent / "app" / "constitution.md"
@@ -16,14 +20,7 @@ CONSTITUTION_PATH = (
16
20
 
17
21
  def _strip_frontmatter(text: str) -> str:
18
22
  """Return Markdown after an optional leading YAML frontmatter block."""
19
- lines = text.splitlines()
20
- if not lines or lines[0] != "---":
21
- return text.strip()
22
- try:
23
- closing = lines.index("---", 1)
24
- except ValueError:
25
- return text.strip()
26
- return "\n".join(lines[closing + 1:]).strip()
23
+ return split_frontmatter(text)[1].strip()
27
24
 
28
25
 
29
26
  def read_constitution(path: Path = CONSTITUTION_PATH) -> str:
@@ -123,12 +123,22 @@ def _is_retired_toolkit_entry(entry: dict) -> bool:
123
123
  return True
124
124
 
125
125
 
126
+ # Handler fields that change *how* a command hook is scheduled, not *what* it
127
+ # runs. A legacy untagged entry that differs only in these is the same hook;
128
+ # otherwise adding `async` or `timeout` in app/hooks.json would leave every
129
+ # existing install running the old copy and the new copy side by side.
130
+ _SCHEDULING_FIELDS = frozenset({
131
+ "async", "asyncRewake", "timeout", "statusMessage", "shell", "once", "if",
132
+ })
133
+
134
+
126
135
  def _entry_signature(entry: dict) -> tuple:
127
136
  """Return the behavior-defining parts of a hook entry.
128
137
 
129
138
  Older ai-toolkit installs wrote hook entries without ``_source``. Matching
130
- on the event, matcher, and handler payload lets current installs remove
131
- those legacy duplicates while preserving unrelated user hooks.
139
+ on the event, matcher, and handler payload (minus scheduling fields) lets
140
+ current installs remove those legacy duplicates while preserving unrelated
141
+ user hooks.
132
142
  """
133
143
  handlers = []
134
144
  for hook in entry.get("hooks", []):
@@ -138,7 +148,7 @@ def _entry_signature(entry: dict) -> tuple:
138
148
  handlers.append(tuple(sorted(
139
149
  (key, value)
140
150
  for key, value in hook.items()
141
- if key != "_source"
151
+ if key != "_source" and key not in _SCHEDULING_FIELDS
142
152
  )))
143
153
  return (entry.get("matcher", ""), tuple(handlers))
144
154
 
@@ -37,7 +37,7 @@ import fnmatch
37
37
  import json
38
38
  import os
39
39
  import sys
40
- from dataclasses import dataclass, field
40
+ from dataclasses import dataclass
41
41
  from pathlib import Path
42
42
 
43
43
  # Token estimate: 4 chars per token is the standard rough heuristic for English / code.