@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.
Files changed (80) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +32 -25
  3. package/app/.claude-plugin/plugin.json +1 -1
  4. package/app/agents/code-reviewer.md +14 -0
  5. package/app/agents/frontend-specialist.md +27 -24
  6. package/app/agents/infrastructure-validator.md +2 -2
  7. package/app/claude-app/hooks/hooks.json +4 -2
  8. package/app/claude-app/skills/ai-toolkit-rules/SKILL.md +74 -17
  9. package/app/hooks/quality-gate.sh +9 -2
  10. package/app/hooks.json +4 -2
  11. package/app/personas/frontend-lead.md +20 -12
  12. package/app/rules/claude-toolkit-rules.md +4 -1
  13. package/app/rules/common/git-team.md +33 -0
  14. package/app/rules/common/git-workflow.md +6 -20
  15. package/app/rules/common/performance.md +25 -1
  16. package/app/rules/common/testing.md +7 -1
  17. package/app/rules/edit-discipline.md +37 -0
  18. package/app/skills/app-builder/SKILL.md +37 -6
  19. package/app/skills/claude-toolkit-rules/SKILL.md +37 -0
  20. package/app/skills/design-engineering/SKILL.md +87 -102
  21. package/app/skills/design-engineering/reference/animation-recipes.md +67 -0
  22. package/app/skills/design-engineering/reference/component-craft.md +28 -0
  23. package/app/skills/documentation-standards/SKILL.md +1 -1
  24. package/app/skills/edit-discipline/SKILL.md +50 -0
  25. package/app/skills/git-conventions/SKILL.md +18 -0
  26. package/app/skills/output-mode/SKILL.md +41 -0
  27. package/app/skills/quality-gates/SKILL.md +23 -0
  28. package/app/skills/review/SKILL.md +10 -0
  29. package/benchmarks/ecosystem-doctor-snapshot.json +17 -15
  30. package/bin/ai-toolkit.js +2 -0
  31. package/kb/procedures/{ecosystem-sync-sop.md → sop-ecosystem-sync.md} +2 -2
  32. package/kb/procedures/{maintenance-sop.md → sop-maintenance.md} +6 -3
  33. package/kb/procedures/{post-release-testing-sop.md → sop-post-release-testing.md} +3 -3
  34. package/kb/procedures/sop-pre-commit.md +96 -0
  35. package/kb/procedures/{release-preparation-sop.md → sop-release.md} +6 -6
  36. package/kb/reference/architecture-overview.md +1 -1
  37. package/kb/reference/cli-reference.md +3 -2
  38. package/kb/reference/copilot-compatibility.md +1 -1
  39. package/kb/reference/global-install-model.md +16 -3
  40. package/kb/reference/hooks-catalog.md +5 -3
  41. package/kb/reference/language-rules.md +28 -10
  42. package/kb/reference/licensing.md +2 -2
  43. package/kb/reference/quick-wins-implementation-summary.md +1 -1
  44. package/kb/reference/supported-tools-registry.md +2 -2
  45. package/kb/reference/unique-features.md +2 -1
  46. package/kb/troubleshooting/plugin-double-load.md +1 -1
  47. package/llms-full.txt +1016 -871
  48. package/llms.txt +6 -5
  49. package/manifest.json +2 -2
  50. package/package.json +6 -2
  51. package/scripts/benchmark_ecosystem.py +0 -1
  52. package/scripts/check_split.py +11 -9
  53. package/scripts/claude_app.py +5 -7
  54. package/scripts/codex_skill_adapter.py +4 -12
  55. package/scripts/compile_slm.py +10 -26
  56. package/scripts/doctor.py +322 -0
  57. package/scripts/evaluate_skills.py +1 -1
  58. package/scripts/frontmatter.py +452 -29
  59. package/scripts/generate_augment_rules.py +4 -4
  60. package/scripts/generate_cursor_mdc.py +2 -3
  61. package/scripts/generate_language_rules_skills.py +8 -14
  62. package/scripts/generate_llms_txt.py +1 -15
  63. package/scripts/generate_opencode_agents.py +0 -1
  64. package/scripts/generate_opencode_skills.py +2 -20
  65. package/scripts/generate_toolkit_rules_skills.py +189 -0
  66. package/scripts/generate_windsurf_rules.py +0 -1
  67. package/scripts/generator_base.py +0 -1
  68. package/scripts/inject_hook_cli.py +15 -2
  69. package/scripts/inject_mcp_cli.py +1 -2
  70. package/scripts/install.py +32 -1
  71. package/scripts/install_git_hooks.py +0 -1
  72. package/scripts/install_steps/ai_tools.py +65 -25
  73. package/scripts/install_steps/markers.py +6 -6
  74. package/scripts/install_steps/skill_scope.py +188 -0
  75. package/scripts/instruction_core.py +5 -8
  76. package/scripts/merge-hooks.py +13 -3
  77. package/scripts/pack_codebase.py +1 -1
  78. package/scripts/surface_manifest.py +6 -7
  79. package/scripts/validate.py +181 -12
  80. /package/kb/procedures/{release-verification-sop.md → sop-release-verification.md} +0 -0
@@ -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}",
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # Copyright 2024-2026 Lukasz Krzemien (biuro@softspark.eu)
4
+ # Source: https://github.com/softspark/ai-toolkit
5
+
6
+ """Generate knowledge skills from the top-level rule files in ``app/rules/``.
7
+
8
+ Each ``app/rules/<stem>.md`` becomes ``app/skills/<stem>/SKILL.md``. The
9
+ per-language directories are not touched here; ``generate_language_rules_skills``
10
+ owns those.
11
+
12
+ Why this exists
13
+ ---------------
14
+ The rules were reachable only as Claude Code user-level files under
15
+ ``~/.claude/rules/``, with the global ``CLAUDE.md`` carrying a pointer to them
16
+ rather than their content. That pointer is a Claude Code idiom. DeepSeek Harness
17
+ reads ``AGENTS.md``, ``AGENTS.local.md``, ``CLAUDE.md`` and ``CLAUDE.local.md``
18
+ and has no rules-directory support at all, so under DSH every one of those rules
19
+ was inert: named in an injected file, never loaded, never applied.
20
+
21
+ Shipping them as skills fixes that without changing the Claude Code surface. A
22
+ skill is discovered by every editor that reads the shared skill catalogue, and
23
+ the catalogue injects each skill's *description* into the session. So the
24
+ description here is written as the rule itself in one imperative line — it is in
25
+ context whether or not the model chooses to load the body.
26
+
27
+ Idempotent: rerunning overwrites the generated ``SKILL.md`` and leaves anything
28
+ else in the skill directory alone.
29
+
30
+ Usage:
31
+ python3 scripts/generate_toolkit_rules_skills.py # write all
32
+ python3 scripts/generate_toolkit_rules_skills.py --check # dry-run
33
+ python3 scripts/generate_toolkit_rules_skills.py --rules git-conventions
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import sys
39
+ from pathlib import Path
40
+ from textwrap import dedent
41
+
42
+ ROOT = Path(__file__).resolve().parent.parent
43
+ RULES_DIR = ROOT / "app" / "rules"
44
+ SKILLS_DIR = ROOT / "app" / "skills"
45
+
46
+ # The description is the rule. It is injected into every session through the
47
+ # skill catalogue, so it has to state the obligation on its own — a reader who
48
+ # never opens the body should still know what is required. Keep it imperative,
49
+ # keep it one line, and put the reasoning in the file under app/rules/.
50
+ DESCRIPTIONS: dict[str, str] = {
51
+ "edit-discipline": (
52
+ "Change files with the edit and write tools, never by rewriting them "
53
+ "through bash (sed, awk, tee, heredoc, redirection), and show `git diff` "
54
+ "before reporting a file-changing task as done. "
55
+ "Triggers: edit, write, modify, refactor, patch, fix, diff, review changes."
56
+ ),
57
+ "git-conventions": (
58
+ "Conventional Commits only (feat, fix, docs, refactor, test, chore); no "
59
+ "AI co-authorship trailer and no AI signature in a commit message. "
60
+ "Triggers: commit, git, message, changelog, release, pull request."
61
+ ),
62
+ "output-mode": (
63
+ "Answer concisely: lead with the result, no preamble, tables over prose, "
64
+ "no trailing restatement of a diff the reader can already see. "
65
+ "Triggers: response style, verbosity, summary, explanation, report."
66
+ ),
67
+ "quality-gates": (
68
+ "Plan before work over an hour, and hold the gates: ruff clean, mypy "
69
+ "--strict clean, pytest coverage above 70 percent, no secrets in code. "
70
+ "Triggers: quality, lint, mypy, pytest, coverage, gate, definition of done."
71
+ ),
72
+ "claude-toolkit-rules": (
73
+ "SoftSpark working agreement: never guess a home directory path, give at "
74
+ "least three alternatives, and apply a devil's advocate critique to "
75
+ "decisions. Triggers: toolkit, conventions, workflow, alternatives, review."
76
+ ),
77
+ }
78
+
79
+
80
+ def _strip_frontmatter(text: str) -> str:
81
+ """Remove YAML frontmatter (--- ... ---) if present."""
82
+ if not text.startswith("---\n"):
83
+ return text
84
+ end = text.find("\n---\n", 4)
85
+ if end == -1:
86
+ return text
87
+ return text[end + len("\n---\n"):]
88
+
89
+
90
+ def _title(stem: str) -> str:
91
+ return stem.replace("-", " ").title()
92
+
93
+
94
+ def _build_skill_md(stem: str, rule_file: Path) -> str:
95
+ description = DESCRIPTIONS[stem]
96
+ body = _strip_frontmatter(rule_file.read_text(encoding="utf-8")).strip()
97
+ frontmatter = dedent(
98
+ f"""\
99
+ ---
100
+ name: {stem}
101
+ description: "{description}"
102
+ effort: low
103
+ user-invocable: false
104
+ allowed-tools: Read
105
+ ---
106
+
107
+ # {_title(stem)}
108
+
109
+ This rule comes from `app/rules/{stem}.md` in ai-toolkit. It applies to
110
+ every task in this workspace, not only when it is loaded.
111
+
112
+ """
113
+ )
114
+ return frontmatter + body + "\n"
115
+
116
+
117
+ def discover_rules() -> list[str]:
118
+ """List top-level rule files that have a description to ship them under.
119
+
120
+ A rule with no entry in DESCRIPTIONS is skipped loudly rather than shipped
121
+ with a generated summary: the description is the part that reaches every
122
+ session, so it is written by a person.
123
+ """
124
+ if not RULES_DIR.is_dir():
125
+ return []
126
+ found: list[str] = []
127
+ for path in sorted(RULES_DIR.glob("*.md")):
128
+ if path.stem in DESCRIPTIONS:
129
+ found.append(path.stem)
130
+ else:
131
+ print(
132
+ f" SKIP: {path.name} has no entry in DESCRIPTIONS; add one to ship it",
133
+ file=sys.stderr,
134
+ )
135
+ return found
136
+
137
+
138
+ def generate(rules: list[str] | None = None, check: bool = False) -> int:
139
+ """Generate skills. Returns the number written, or that would be written."""
140
+ selected = rules if rules else discover_rules()
141
+ written = 0
142
+ for stem in selected:
143
+ rule_file = RULES_DIR / f"{stem}.md"
144
+ if not rule_file.is_file():
145
+ print(f" SKIP: {stem} (app/rules/{stem}.md missing)", file=sys.stderr)
146
+ continue
147
+ if stem not in DESCRIPTIONS:
148
+ print(f" SKIP: {stem} (no description)", file=sys.stderr)
149
+ continue
150
+
151
+ skill_dir = SKILLS_DIR / stem
152
+ skill_md = skill_dir / "SKILL.md"
153
+ content = _build_skill_md(stem, rule_file)
154
+
155
+ if check:
156
+ existing = skill_md.read_text(encoding="utf-8") if skill_md.is_file() else ""
157
+ status = "OK" if existing == content else "DIFF"
158
+ print(f" [{status}] {skill_md.relative_to(ROOT)}")
159
+ if existing != content:
160
+ written += 1
161
+ continue
162
+
163
+ skill_dir.mkdir(parents=True, exist_ok=True)
164
+ skill_md.write_text(content, encoding="utf-8")
165
+ print(f" wrote {skill_md.relative_to(ROOT)}")
166
+ written += 1
167
+ return written
168
+
169
+
170
+ def main() -> int:
171
+ parser = argparse.ArgumentParser(description=__doc__)
172
+ parser.add_argument("--check", action="store_true", help="report drift, write nothing")
173
+ parser.add_argument("--rules", help="comma-separated rule stems")
174
+ args = parser.parse_args()
175
+
176
+ rules = [r.strip() for r in args.rules.split(",")] if args.rules else None
177
+ written = generate(rules, check=args.check)
178
+ if args.check:
179
+ if written:
180
+ print(f"{written} generated skill(s) differ from app/rules/", file=sys.stderr)
181
+ return 1
182
+ print("toolkit rule skills are up to date")
183
+ return 0
184
+ print(f"{written} toolkit rule skill(s) written")
185
+ return 0
186
+
187
+
188
+ if __name__ == "__main__":
189
+ raise SystemExit(main())
@@ -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():