@softspark/ai-toolkit 4.31.0 → 4.32.1

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 (58) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/README.md +21 -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/app/skills/analyze/scripts/complexity.py +3 -0
  13. package/app/skills/deploy/scripts/pre_deploy_check.py +13 -6
  14. package/app/skills/docs/scripts/doc-inventory.py +3 -0
  15. package/app/skills/explain/scripts/dependency-graph.py +3 -0
  16. package/app/skills/migrate/scripts/migration-status.py +3 -0
  17. package/app/skills/refactor/scripts/refactor-scan.py +3 -0
  18. package/benchmarks/ecosystem-doctor-snapshot.json +17 -15
  19. package/bin/ai-toolkit.js +2 -0
  20. package/kb/procedures/sop-maintenance.md +6 -3
  21. package/kb/reference/cli-reference.md +3 -2
  22. package/kb/reference/global-install-model.md +16 -3
  23. package/kb/reference/hooks-catalog.md +5 -3
  24. package/kb/reference/language-rules.md +28 -10
  25. package/kb/reference/plugin-pack-conventions.md +3 -3
  26. package/kb/reference/unique-features.md +2 -1
  27. package/llms-full.txt +63 -25
  28. package/manifest.json +2 -2
  29. package/package.json +5 -2
  30. package/scripts/benchmark_ecosystem.py +0 -1
  31. package/scripts/check_split.py +11 -9
  32. package/scripts/claude_app.py +5 -7
  33. package/scripts/codex_skill_adapter.py +4 -12
  34. package/scripts/compile_slm.py +10 -26
  35. package/scripts/doctor.py +322 -0
  36. package/scripts/evaluate_skills.py +1 -1
  37. package/scripts/frontmatter.py +452 -29
  38. package/scripts/generate_augment_rules.py +4 -4
  39. package/scripts/generate_cursor_mdc.py +2 -3
  40. package/scripts/generate_language_rules_skills.py +8 -14
  41. package/scripts/generate_llms_txt.py +1 -15
  42. package/scripts/generate_opencode_agents.py +0 -1
  43. package/scripts/generate_opencode_skills.py +2 -20
  44. package/scripts/generate_windsurf_rules.py +0 -1
  45. package/scripts/generator_base.py +0 -1
  46. package/scripts/inject_hook_cli.py +15 -2
  47. package/scripts/inject_mcp_cli.py +1 -2
  48. package/scripts/install.py +32 -1
  49. package/scripts/install_git_hooks.py +0 -1
  50. package/scripts/install_steps/ai_tools.py +65 -25
  51. package/scripts/install_steps/markers.py +6 -6
  52. package/scripts/install_steps/skill_scope.py +188 -0
  53. package/scripts/instruction_core.py +5 -8
  54. package/scripts/merge-hooks.py +13 -3
  55. package/scripts/pack_codebase.py +1 -1
  56. package/scripts/plugin.py +128 -16
  57. package/scripts/surface_manifest.py +6 -7
  58. package/scripts/validate.py +180 -11
package/scripts/plugin.py CHANGED
@@ -2006,6 +2006,16 @@ def _apply_asset_install(
2006
2006
  "inode": version.inode,
2007
2007
  }
2008
2008
  print(f" Installed plugin {spec.kind}: {spec.path.name}")
2009
+ return _merge_asset_ownership(previous_ownership, name, editor, produced_entries)
2010
+
2011
+
2012
+ def _merge_asset_ownership(
2013
+ previous_ownership: dict | None,
2014
+ name: str,
2015
+ editor: str,
2016
+ produced_entries: dict[str, dict],
2017
+ ) -> dict:
2018
+ """Fold one editor's freshly written assets into the pack's ownership record."""
2009
2019
  entries = _asset_entries(previous_ownership, name)
2010
2020
  entries.update(produced_entries)
2011
2021
  consumers = _shared_asset_consumers(previous_ownership, name)
@@ -2022,6 +2032,37 @@ def _apply_asset_install(
2022
2032
  }
2023
2033
 
2024
2034
 
2035
+ def _ownership_from_copied_assets(
2036
+ name: str,
2037
+ editor: str,
2038
+ pack_dir: Path,
2039
+ hook_specs: list[dict],
2040
+ previous_ownership: dict | None,
2041
+ ) -> dict:
2042
+ """Record what the non-transactional Claude/Codex install just copied.
2043
+
2044
+ ``_copy_plugin_hook_scripts`` and ``_copy_plugin_scripts`` write the same
2045
+ paths ``_prepare_asset_specs`` describes but never recorded them, so
2046
+ removal had nothing to verify against and preserved every file as
2047
+ "untracked" (v4.32.1). Reading the versions back from disk after the copy
2048
+ gives removal the same sha256/mode/inode contract the JSON runtimes get.
2049
+ """
2050
+ produced: dict[str, dict] = {}
2051
+ for spec in _prepare_asset_specs(name, pack_dir, hook_specs):
2052
+ version = _read_file_version(spec.path)
2053
+ if version is None:
2054
+ continue
2055
+ produced[spec.key] = {
2056
+ "path": str(spec.path),
2057
+ "kind": spec.kind,
2058
+ "sha256": hashlib.sha256(version.content).hexdigest(),
2059
+ "mode": version.mode,
2060
+ "device": version.device,
2061
+ "inode": version.inode,
2062
+ }
2063
+ return _merge_asset_ownership(previous_ownership, name, editor, produced)
2064
+
2065
+
2025
2066
  def _preflight_asset_removal(
2026
2067
  transaction: PluginFileTransaction,
2027
2068
  ownership: dict | None,
@@ -2075,6 +2116,74 @@ def _apply_asset_removal(
2075
2116
  print(f" WARN preserved changed or user-owned plugin asset: {path}")
2076
2117
 
2077
2118
 
2119
+ def _remove_owned_plugin_assets(state: dict | None, name: str, editor: str) -> None:
2120
+ """Delete the hook and script files this pack installed for ``editor``.
2121
+
2122
+ Claude and Codex removal never consulted ``shared_asset_ownership``: the
2123
+ files were written on install, recorded, and then reported as "untracked"
2124
+ on remove because nothing looked the record up. Every pack left its hooks
2125
+ and ``plugin-scripts/<name>/`` behind (v4.32.1).
2126
+
2127
+ Same rules as the transactional runtimes: an entry is deleted only when
2128
+ this editor consumes it, no other editor still does, and the file on disk
2129
+ is byte- and inode-identical to what install recorded. Anything else
2130
+ (user edits, a file install never wrote, a symlink) is preserved and named.
2131
+ """
2132
+ ownership = _shared_asset_ownership_for(state or {}, name)
2133
+ entries = _asset_entries(ownership, name)
2134
+ consumers = _shared_asset_consumers(ownership, name)
2135
+ editor_keys = set(consumers.get(editor, ()))
2136
+ other_keys = {
2137
+ key
2138
+ for consumer, keys in consumers.items()
2139
+ if consumer != editor
2140
+ for key in keys
2141
+ }
2142
+ scripts_root = TOOLKIT_DATA_DIR / "plugin-scripts" / name
2143
+ hook_prefix = f"plugin-{name}-"
2144
+ handled: set[Path] = set()
2145
+
2146
+ for key, entry in entries.items():
2147
+ raw_path = entry.get("path")
2148
+ if not isinstance(raw_path, str):
2149
+ continue
2150
+ path = Path(raw_path)
2151
+ handled.add(path.absolute())
2152
+ if key not in editor_keys or key in other_keys:
2153
+ continue
2154
+ kind = entry.get("kind")
2155
+ if kind == "hook":
2156
+ safe = path.parent.absolute() == HOOKS_DIR.absolute() and path.name.startswith(hook_prefix)
2157
+ elif kind == "script":
2158
+ safe = path.parent.absolute() == scripts_root.absolute()
2159
+ else:
2160
+ safe = False
2161
+ if not safe:
2162
+ print(f" WARN refused to remove plugin asset outside its owned root: {path}")
2163
+ continue
2164
+ if path.is_symlink():
2165
+ print(f" WARN preserved symlinked plugin asset: {path}")
2166
+ continue
2167
+ version = _read_file_version(path)
2168
+ if version is None:
2169
+ continue
2170
+ if not _asset_entry_matches(path, version, entry):
2171
+ print(f" WARN preserved changed plugin asset: {path}")
2172
+ continue
2173
+ path.unlink()
2174
+ print(f" Removed plugin asset: {path.name}")
2175
+
2176
+ for hook in sorted(HOOKS_DIR.glob(f"{hook_prefix}*")):
2177
+ if hook.absolute() not in handled:
2178
+ print(f" WARN preserved untracked plugin hook: {hook}")
2179
+ if scripts_root.is_dir():
2180
+ for leftover in sorted(scripts_root.iterdir()):
2181
+ if leftover.absolute() not in handled:
2182
+ print(f" WARN preserved untracked plugin script: {leftover}")
2183
+ if not any(scripts_root.iterdir()):
2184
+ scripts_root.rmdir()
2185
+
2186
+
2078
2187
  def _state_after_removal(
2079
2188
  state: dict,
2080
2189
  editor: str,
@@ -2616,19 +2725,15 @@ def _remove_claude_pack_links(pack: dict, pack_dir: Path) -> None:
2616
2725
 
2617
2726
 
2618
2727
  def remove_pack_claude(
2619
- name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool
2728
+ name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool,
2729
+ state: dict | None = None,
2620
2730
  ) -> bool:
2621
2731
  hook_specs = _resolve_pack_hooks(pack, pack_dir)
2622
2732
  rule_specs = _resolve_pack_rules(pack, pack_dir)
2623
2733
  _remove_claude_pack_links(pack, pack_dir)
2624
2734
 
2625
2735
  if not keep_shared_assets:
2626
- for hook in HOOKS_DIR.glob(f"plugin-{name}-*"):
2627
- print(f" WARN preserved untracked plugin hook: {hook}")
2628
-
2629
- scripts_dir = TOOLKIT_DATA_DIR / "plugin-scripts" / name
2630
- if scripts_dir.is_dir():
2631
- print(f" WARN preserved untracked plugin scripts: {scripts_dir}")
2736
+ _remove_owned_plugin_assets(state, name, "claude")
2632
2737
 
2633
2738
  if any(not spec["is_core"] for spec in hook_specs):
2634
2739
  _strip_claude_hooks(name)
@@ -3000,7 +3105,8 @@ def install_pack_codex(name: str, pack: dict, pack_dir: Path) -> bool:
3000
3105
 
3001
3106
 
3002
3107
  def remove_pack_codex(
3003
- name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool
3108
+ name: str, pack: dict, pack_dir: Path, *, keep_shared_assets: bool,
3109
+ state: dict | None = None,
3004
3110
  ) -> bool:
3005
3111
  _assert_safe_codex_surface()
3006
3112
  _strip_codex_hooks(name)
@@ -3016,14 +3122,10 @@ def remove_pack_codex(
3016
3122
  print(f" WARN preserved user-owned Codex hook: {hook.name}")
3017
3123
 
3018
3124
  if not keep_shared_assets:
3019
- # Clean paths used by releases before native Codex plugin assets moved
3020
- # under $CODEX_HOME. Claude still owns these when installed for both.
3021
- for hook in HOOKS_DIR.glob(f"plugin-{name}-*"):
3022
- print(f" WARN preserved untracked plugin hook: {hook}")
3023
-
3024
- scripts_dir = TOOLKIT_DATA_DIR / "plugin-scripts" / name
3025
- if scripts_dir.is_dir():
3026
- print(f" WARN preserved untracked plugin scripts: {scripts_dir}")
3125
+ # Paths used by releases before native Codex plugin assets moved under
3126
+ # $CODEX_HOME. Claude still owns these when installed for both, which
3127
+ # the ownership consumers encode; only Codex-consumed entries go.
3128
+ _remove_owned_plugin_assets(state, name, "codex")
3027
3129
 
3028
3130
  _remove_codex_rules(name)
3029
3131
  print(f" Done: removed {name} from codex")
@@ -3202,6 +3304,14 @@ def _install_pack_locked(
3202
3304
  if transaction_snapshots is not None:
3203
3305
  _restore_file_snapshots(transaction_snapshots)
3204
3306
  return False
3307
+ if editor in ("claude", "codex"):
3308
+ asset_ownership = _ownership_from_copied_assets(
3309
+ name,
3310
+ editor,
3311
+ pack_dir,
3312
+ _resolve_pack_hooks(pack, pack_dir),
3313
+ _shared_asset_ownership_for(state, name),
3314
+ )
3205
3315
 
3206
3316
  if hook_config_update is not None:
3207
3317
  if transaction_snapshots is not None:
@@ -3362,6 +3472,7 @@ def _remove_pack_locked(
3362
3472
  pack,
3363
3473
  pack_dir,
3364
3474
  keep_shared_assets=keep_shared_assets,
3475
+ state=state,
3365
3476
  )
3366
3477
  elif editor == "codex":
3367
3478
  ok = remove_pack_codex(
@@ -3369,6 +3480,7 @@ def _remove_pack_locked(
3369
3480
  pack,
3370
3481
  pack_dir,
3371
3482
  keep_shared_assets=keep_shared_assets,
3483
+ state=state,
3372
3484
  )
3373
3485
  elif editor in JSON_HOOK_RUNTIMES:
3374
3486
  ok = remove_pack_json_runtime(
@@ -44,6 +44,7 @@ from pathlib import Path
44
44
 
45
45
  sys.path.insert(0, str(Path(__file__).resolve().parent))
46
46
  from _common import toolkit_dir as default_toolkit_dir
47
+ from frontmatter import FrontmatterError, load_frontmatter
47
48
 
48
49
  MANIFEST_RELPATH = Path("app") / "surface.json"
49
50
 
@@ -52,14 +53,12 @@ CLI_COMMAND_RE = re.compile(r"^\s+'?([a-z][a-z0-9-]*)'?\s*:")
52
53
 
53
54
 
54
55
  def _frontmatter_fields(path: Path) -> set[str]:
55
- parts = path.read_text(encoding="utf-8").split("---")
56
- if len(parts) < 3:
56
+ """Top-level frontmatter keys of one file; a file the subset parser
57
+ refuses contributes nothing, which the removal check then reports."""
58
+ try:
59
+ return {key for key in load_frontmatter(path, strict=False) if FM_FIELD_RE.match(f"{key}:")}
60
+ except FrontmatterError:
57
61
  return set()
58
- return {
59
- m.group(1)
60
- for line in parts[1].splitlines()
61
- if (m := FM_FIELD_RE.match(line))
62
- }
63
62
 
64
63
 
65
64
  def collect_surface(tk_dir: Path) -> dict:
@@ -27,6 +27,7 @@ from pathlib import Path
27
27
 
28
28
  sys.path.insert(0, str(Path(__file__).resolve().parent))
29
29
  from _common import toolkit_dir as default_toolkit_dir, frontmatter_field
30
+ from frontmatter import FrontmatterError, parse_scalar
30
31
  from plugin_schema import validate_manifest as _validate_plugin_manifest_schema
31
32
  from plugin_schema import validate_references as _validate_plugin_references
32
33
 
@@ -152,16 +153,29 @@ MAX_EMITTED_SKILL_NODES = 10_000
152
153
  SKILL_BODY_BUDGET_ERROR = 20_000
153
154
  SKILL_BODY_BUDGET_WARN = 18_000
154
155
 
156
+ # Skill description budget, in characters of the decoded description text.
157
+ #
158
+ # Every model-invocable skill's description sits in the listing that loads at
159
+ # the start of every session, so a long description is paid for on every turn
160
+ # of every user, not just when the skill fires. The Agent Skills spec caps the
161
+ # field at 1024 characters; anything past that is truncated by the runtime.
162
+ SKILL_DESCRIPTION_LIMIT = 1024
163
+ SKILL_DESCRIPTION_BUDGET_WARN = 400
164
+
155
165
  VALID_RULE_CATEGORIES = frozenset({
156
166
  "coding-style",
157
167
  "testing",
158
168
  "security",
159
169
  "performance",
160
170
  "git-workflow",
171
+ "git-team",
161
172
  "patterns",
162
173
  "frameworks",
163
174
  })
164
175
 
176
+ # Install profiles a common rule may restrict itself to via `profiles:`.
177
+ VALID_RULE_PROFILES = frozenset({"minimal", "standard", "strict", "full"})
178
+
165
179
  COMMON_RULE_CATEGORIES = frozenset({
166
180
  "coding-style",
167
181
  "testing",
@@ -256,6 +270,139 @@ def _fm_has(lines: list[str], field: str) -> bool:
256
270
  return any(line.startswith(f"{field}:") for line in lines)
257
271
 
258
272
 
273
+ def _fm_description(fm_lines: list[str]) -> tuple[str, str]:
274
+ """Return ``(raw first-line value, decoded text)`` for ``description:``.
275
+
276
+ Handles the three spellings skills use: a plain scalar, a quoted scalar,
277
+ and a block scalar (``>-`` / ``|``) whose text continues on indented lines.
278
+ """
279
+ for index, line in enumerate(fm_lines):
280
+ if not line.startswith("description:"):
281
+ continue
282
+ raw = line[len("description:"):].strip()
283
+ if raw and raw[0] in ">|":
284
+ continuation: list[str] = []
285
+ for nxt in fm_lines[index + 1:]:
286
+ if nxt.strip() and not nxt[0].isspace():
287
+ break
288
+ continuation.append(nxt.strip())
289
+ return raw, " ".join(part for part in continuation if part)
290
+ return raw, _frontmatter_scalar(raw)
291
+ return "", ""
292
+
293
+
294
+ def _validate_skill_description(label: str, fm_lines: list[str],
295
+ vr: ValidationResult) -> None:
296
+ """Enforce the description budget and reject ambiguous plain scalars."""
297
+ raw, text = _fm_description(fm_lines)
298
+ if not raw:
299
+ return
300
+ is_plain = raw[0] not in "\"'>|"
301
+ if is_plain and (": " in raw or " #" in raw):
302
+ # A plain scalar with `: ` or ` #` parses as a nested mapping or a
303
+ # comment under strict YAML. Claude Code tolerates it today; a stricter
304
+ # parser drops every field, including allowed-tools, without a warning.
305
+ vr.error(
306
+ f"{label}: description is an unquoted scalar containing ': ' or ' #' "
307
+ "- quote it or use a '>-' block scalar"
308
+ )
309
+ length = len(text)
310
+ if length > SKILL_DESCRIPTION_LIMIT:
311
+ vr.error(
312
+ f"{label}: description is {length} characters "
313
+ f"(limit {SKILL_DESCRIPTION_LIMIT}) - the runtime truncates it"
314
+ )
315
+ elif length > SKILL_DESCRIPTION_BUDGET_WARN:
316
+ vr.warn(
317
+ f"{label}: description is {length} characters "
318
+ f"(budget {SKILL_DESCRIPTION_BUDGET_WARN}) - it loads in every session"
319
+ )
320
+
321
+
322
+ def _validate_rule_profiles(rel: str, language: str, fm_lines: list[str],
323
+ vr: ValidationResult) -> int:
324
+ """Validate an optional ``profiles:`` gate on a common rule source file.
325
+
326
+ Same block-list form as ``paths``; every value must be an existing install
327
+ profile. Per-language rules ship as skills and have no profile, so the key
328
+ is rejected outside ``app/rules/common/``.
329
+ """
330
+ if language != "common":
331
+ vr.error(f"{rel} - profiles is only meaningful for common rules")
332
+ return 1
333
+ errors = 0
334
+ items: list[str] = []
335
+ in_block = False
336
+ for line in fm_lines:
337
+ if line.startswith("profiles:"):
338
+ if line[len("profiles:"):].strip():
339
+ vr.error(f"{rel} - profiles must be a block list, not inline: {line.strip()}")
340
+ return 1
341
+ in_block = True
342
+ continue
343
+ if not in_block:
344
+ continue
345
+ stripped = line.strip()
346
+ if not stripped:
347
+ continue
348
+ if not line.startswith(" - "):
349
+ break
350
+ item = stripped[2:].strip().strip('"')
351
+ if item not in VALID_RULE_PROFILES:
352
+ vr.error(
353
+ f"{rel} - unknown profile '{item}' "
354
+ f"(valid: {', '.join(sorted(VALID_RULE_PROFILES))})"
355
+ )
356
+ errors += 1
357
+ continue
358
+ items.append(item)
359
+ if not items and errors == 0:
360
+ vr.error(f"{rel} - profiles block is empty (drop the key to ship in every profile)")
361
+ errors += 1
362
+ return errors
363
+
364
+
365
+ def _validate_rule_paths(rel: str, fm_lines: list[str], vr: ValidationResult) -> int:
366
+ """Validate an optional ``paths:`` scope block on a rule source file.
367
+
368
+ Mirrors what ``install_steps.ai_tools._rule_paths`` reads: a bare
369
+ ``paths:`` key followed by one or more `` - "glob"`` items. Returns the
370
+ number of errors reported.
371
+ """
372
+ errors = 0
373
+ items: list[str] = []
374
+ in_paths = False
375
+ for line in fm_lines:
376
+ if line.startswith("paths:"):
377
+ if line[len("paths:"):].strip():
378
+ vr.error(f"{rel} - paths must be a block list, not inline: {line.strip()}")
379
+ return 1
380
+ in_paths = True
381
+ continue
382
+ if not in_paths:
383
+ continue
384
+ stripped = line.strip()
385
+ if not stripped:
386
+ continue
387
+ if not line.startswith(" - "):
388
+ break
389
+ item = stripped[2:].strip()
390
+ if len(item) < 3 or item[0] != '"' or item[-1] != '"':
391
+ vr.error(f"{rel} - paths entry must be a double-quoted glob: {stripped}")
392
+ errors += 1
393
+ continue
394
+ glob_value = item[1:-1]
395
+ if not glob_value or any(ch in glob_value for ch in ' \\"'):
396
+ vr.error(f"{rel} - paths glob contains whitespace, backslash, or quote: {stripped}")
397
+ errors += 1
398
+ continue
399
+ items.append(glob_value)
400
+ if not items and errors == 0:
401
+ vr.error(f"{rel} - paths block is empty (drop the key for an always-on rule)")
402
+ errors += 1
403
+ return errors
404
+
405
+
259
406
  def _validate_invocation_metadata(label: str, fm_lines: list[str],
260
407
  vr: ValidationResult) -> None:
261
408
  """Reject metadata spellings that DSH interprets differently or ignores."""
@@ -296,14 +443,16 @@ def _validate_invocation_metadata(label: str, fm_lines: list[str],
296
443
 
297
444
 
298
445
  def _frontmatter_scalar(raw_value: str) -> str:
299
- """Decode the simple scalar forms used by emitted skill metadata."""
300
- value = raw_value.strip()
301
- if not value:
302
- return ""
303
- quoted = re.fullmatch(r'''(["'])(.*?)\1(?:\s+#.*)?''', value)
304
- if quoted:
305
- return quoted.group(2).strip()
306
- return re.split(r"\s+#", value, maxsplit=1)[0].strip()
446
+ """Decode one scalar the way the shared parser does, never raising.
447
+
448
+ Validation reports problems as findings; a value the subset parser
449
+ refuses (anchor, tag, unterminated quote) decodes to its raw text here and
450
+ is caught by the dedicated checks that follow.
451
+ """
452
+ try:
453
+ return parse_scalar(raw_value, strict=False)
454
+ except FrontmatterError:
455
+ return raw_value.strip()
307
456
 
308
457
 
309
458
  def _parse_supported_top_level_entry(
@@ -672,9 +821,7 @@ def _validate_skill_frontmatter(tk_dir: Path, skill_path: Path,
672
821
  f"(budget {SKILL_BODY_BUDGET_WARN}) - move detail into reference/"
673
822
  )
674
823
 
675
- desc_value = _fm_field(fm_lines, "description")
676
- if len(desc_value) > 1024:
677
- vr.warn(f"{name} - Description exceeds 1024 characters")
824
+ _validate_skill_description(f"skills/{name}/SKILL.md", fm_lines, vr)
678
825
 
679
826
 
680
827
  def _validate_skill_script_invocations(skill_path: Path, vr: ValidationResult) -> None:
@@ -1104,6 +1251,17 @@ def validate_language_rules(tk_dir: Path, vr: ValidationResult) -> None:
1104
1251
  vr.error(f"{rel} filename does not match category '{category}'")
1105
1252
  rule_errors += 1
1106
1253
 
1254
+ # Optional Claude Code `paths` scope. The installer reads only the
1255
+ # block-list form (` - "glob"`), so reject anything else here
1256
+ # rather than letting a rule silently fall back to always-on.
1257
+ if _fm_has(fm_lines, "paths"):
1258
+ rule_errors += _validate_rule_paths(rel, fm_lines, vr)
1259
+
1260
+ # Optional `profiles` gate (common rules only): same block-list
1261
+ # form, values limited to the install profiles that exist.
1262
+ if _fm_has(fm_lines, "profiles"):
1263
+ rule_errors += _validate_rule_profiles(rel, language, fm_lines, vr)
1264
+
1107
1265
  for category in sorted(expected - seen):
1108
1266
  vr.error(f"app/rules/{language} missing required rule category: {category}")
1109
1267
  rule_errors += 1
@@ -1230,6 +1388,17 @@ def _validate_pack_refs(tk_dir: Path, pack_path: Path, d: dict,
1230
1388
  for err in ref_errors:
1231
1389
  vr.error(f"app/plugins/{pack_name}/plugin.json {err}")
1232
1390
 
1391
+ # Pack skills bypass validate_skills() (they live outside app/skills), yet
1392
+ # they are installed as skills. Apply the same description gate so a pack
1393
+ # cannot ship a listing-bloating or strict-YAML-hostile description.
1394
+ pack_skills_dir = pack_path / "skills"
1395
+ if pack_skills_dir.is_dir():
1396
+ for skill_file in sorted(pack_skills_dir.glob("*/SKILL.md")):
1397
+ if not _has_frontmatter(skill_file):
1398
+ continue
1399
+ rel = str(skill_file.relative_to(tk_dir))
1400
+ _validate_skill_description(rel, _parse_frontmatter_lines(skill_file), vr)
1401
+
1233
1402
  hooks_dir = pack_path / "hooks"
1234
1403
  if hooks_dir.is_dir():
1235
1404
  for hook in sorted(hooks_dir.glob("*.sh")):