crucible-mcp 1.2.0__tar.gz → 1.3.0__tar.gz

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 (88) hide show
  1. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/PKG-INFO +1 -1
  2. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/pyproject.toml +1 -1
  3. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/cli.py +153 -4
  4. crucible_mcp-1.3.0/src/crucible/ignore.py +294 -0
  5. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/review/core.py +60 -7
  6. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/server.py +13 -0
  7. crucible_mcp-1.3.0/src/crucible/skills/code-hygiene/SKILL.md +83 -0
  8. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible_mcp.egg-info/PKG-INFO +1 -1
  9. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible_mcp.egg-info/SOURCES.txt +3 -0
  10. crucible_mcp-1.3.0/tests/test_ignore.py +194 -0
  11. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_skills.py +5 -4
  12. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/README.md +0 -0
  13. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/setup.cfg +0 -0
  14. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/__init__.py +0 -0
  15. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/domain/__init__.py +0 -0
  16. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/domain/detection.py +0 -0
  17. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/__init__.py +0 -0
  18. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/assertions.py +0 -0
  19. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/budget.py +0 -0
  20. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/bundled/error-handling.yaml +0 -0
  21. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/bundled/security.yaml +0 -0
  22. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/bundled/smart-contract.yaml +0 -0
  23. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/compliance.py +0 -0
  24. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/models.py +0 -0
  25. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/enforcement/patterns.py +0 -0
  26. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/errors.py +0 -0
  27. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/hooks/__init__.py +0 -0
  28. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/hooks/claudecode.py +0 -0
  29. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/hooks/precommit.py +0 -0
  30. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/__init__.py +0 -0
  31. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/loader.py +0 -0
  32. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/API_DESIGN.md +0 -0
  33. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/COMMITS.md +0 -0
  34. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/DATABASE.md +0 -0
  35. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/DOCUMENTATION.md +0 -0
  36. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/ERROR_HANDLING.md +0 -0
  37. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/FP.md +0 -0
  38. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/GITIGNORE.md +0 -0
  39. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/OBSERVABILITY.md +0 -0
  40. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/PRECOMMIT.md +0 -0
  41. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/SECURITY.md +0 -0
  42. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/SMART_CONTRACT.md +0 -0
  43. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/SYSTEM_DESIGN.md +0 -0
  44. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/TESTING.md +0 -0
  45. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/knowledge/principles/TYPE_SAFETY.md +0 -0
  46. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/models.py +0 -0
  47. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/review/__init__.py +0 -0
  48. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/__init__.py +0 -0
  49. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/accessibility-engineer/SKILL.md +0 -0
  50. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/backend-engineer/SKILL.md +0 -0
  51. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/customer-success/SKILL.md +0 -0
  52. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/data-engineer/SKILL.md +0 -0
  53. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/devops-engineer/SKILL.md +0 -0
  54. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/fde-engineer/SKILL.md +0 -0
  55. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/formal-verification/SKILL.md +0 -0
  56. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/gas-optimizer/SKILL.md +0 -0
  57. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/incident-responder/SKILL.md +0 -0
  58. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/loader.py +0 -0
  59. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/mev-researcher/SKILL.md +0 -0
  60. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/mobile-engineer/SKILL.md +0 -0
  61. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/performance-engineer/SKILL.md +0 -0
  62. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/product-engineer/SKILL.md +0 -0
  63. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/protocol-architect/SKILL.md +0 -0
  64. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/security-engineer/SKILL.md +0 -0
  65. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/tech-lead/SKILL.md +0 -0
  66. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/uiux-engineer/SKILL.md +0 -0
  67. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/skills/web3-engineer/SKILL.md +0 -0
  68. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/synthesis/__init__.py +0 -0
  69. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/tools/__init__.py +0 -0
  70. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/tools/delegation.py +0 -0
  71. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible/tools/git.py +0 -0
  72. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible_mcp.egg-info/dependency_links.txt +0 -0
  73. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible_mcp.egg-info/entry_points.txt +0 -0
  74. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible_mcp.egg-info/requires.txt +0 -0
  75. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/src/crucible_mcp.egg-info/top_level.txt +0 -0
  76. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_cli.py +0 -0
  77. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_compliance.py +0 -0
  78. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_detection.py +0 -0
  79. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_enforcement.py +0 -0
  80. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_full_review.py +0 -0
  81. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_git.py +0 -0
  82. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_hooks_cli.py +0 -0
  83. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_integration.py +0 -0
  84. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_knowledge.py +0 -0
  85. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_precommit.py +0 -0
  86. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_server.py +0 -0
  87. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_skills_loader.py +0 -0
  88. {crucible_mcp-1.2.0 → crucible_mcp-1.3.0}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crucible-mcp
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Code review MCP server for Claude. Not affiliated with Atlassian.
5
5
  Author: be.nvy
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "crucible-mcp"
3
- version = "1.2.0"
3
+ version = "1.3.0"
4
4
  description = "Code review MCP server for Claude. Not affiliated with Atlassian."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -8,6 +8,7 @@ import sys
8
8
  from pathlib import Path
9
9
 
10
10
  from crucible.enforcement.models import ComplianceConfig
11
+ from crucible.ignore import load_ignore_spec
11
12
 
12
13
  # Skills directories
13
14
  SKILLS_BUNDLED = Path(__file__).parent / "skills"
@@ -541,12 +542,12 @@ def _cmd_review_no_git(args: argparse.Namespace, path: str) -> int:
541
542
  if path_obj.is_file():
542
543
  files_to_analyze = [str(path_obj)]
543
544
  else:
544
- # Recursively find files, respecting common ignores
545
- ignore_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv", "build", "dist"}
545
+ # Load ignore patterns (.crucibleignore + defaults)
546
+ ignore_spec = load_ignore_spec(path_obj)
546
547
  for file_path in path_obj.rglob("*"):
547
548
  if file_path.is_file():
548
- # Skip ignored directories
549
- if any(ignored in file_path.parts for ignored in ignore_dirs):
549
+ rel_path = file_path.relative_to(path_obj)
550
+ if ignore_spec.is_ignored(str(rel_path), is_dir=False):
550
551
  continue
551
552
  files_to_analyze.append(str(file_path))
552
553
 
@@ -1910,6 +1911,113 @@ def cmd_config_show(args: argparse.Namespace) -> int:
1910
1911
  return 0
1911
1912
 
1912
1913
 
1914
+ # --- Ignore commands ---
1915
+
1916
+
1917
+ def cmd_ignore_show(args: argparse.Namespace) -> int:
1918
+ """Show active ignore patterns and their sources."""
1919
+ from crucible.ignore import (
1920
+ DEFAULT_PATTERNS,
1921
+ IGNORE_PROJECT,
1922
+ IGNORE_USER,
1923
+ clear_ignore_cache,
1924
+ load_ignore_spec,
1925
+ )
1926
+
1927
+ clear_ignore_cache()
1928
+ spec = load_ignore_spec()
1929
+
1930
+ print("Ignore patterns (files/directories excluded from review)")
1931
+ print()
1932
+
1933
+ # Show default patterns
1934
+ print(f"Defaults ({len(DEFAULT_PATTERNS)} patterns):")
1935
+ print(" node_modules/, .git/, __pycache__/, .venv/, dist/, build/,")
1936
+ print(" .next/, .nuxt/, package-lock.json, yarn.lock, *.log, ...")
1937
+ print()
1938
+
1939
+ # Show user patterns
1940
+ print("User (~/.claude/crucible/.crucibleignore):")
1941
+ if IGNORE_USER.exists():
1942
+ user_content = IGNORE_USER.read_text().strip()
1943
+ for line in user_content.splitlines()[:5]:
1944
+ print(f" {line}")
1945
+ lines = user_content.splitlines()
1946
+ if len(lines) > 5:
1947
+ print(f" ... and {len(lines) - 5} more")
1948
+ else:
1949
+ print(" (not set)")
1950
+ print()
1951
+
1952
+ # Show project patterns
1953
+ print("Project (.crucible/.crucibleignore):")
1954
+ if IGNORE_PROJECT.exists():
1955
+ proj_content = IGNORE_PROJECT.read_text().strip()
1956
+ for line in proj_content.splitlines()[:5]:
1957
+ print(f" {line}")
1958
+ lines = proj_content.splitlines()
1959
+ if len(lines) > 5:
1960
+ print(f" ... and {len(lines) - 5} more")
1961
+ else:
1962
+ print(" (not set)")
1963
+ print()
1964
+
1965
+ print(f"Total: {len(spec.patterns)} patterns active (source: {spec.source})")
1966
+ return 0
1967
+
1968
+
1969
+ def cmd_ignore_init(args: argparse.Namespace) -> int:
1970
+ """Initialize a project .crucibleignore file."""
1971
+ from crucible.ignore import IGNORE_PROJECT
1972
+
1973
+ # Create .crucible directory if needed
1974
+ crucible_dir = Path(".crucible")
1975
+ crucible_dir.mkdir(exist_ok=True)
1976
+
1977
+ if IGNORE_PROJECT.exists() and not args.force:
1978
+ print(f"✗ {IGNORE_PROJECT} already exists (use --force to overwrite)")
1979
+ return 1
1980
+
1981
+ template = """# Project-specific ignore patterns for Crucible
1982
+ # These are in addition to the built-in defaults (node_modules, .git, etc.)
1983
+ # Syntax: gitignore-style patterns
1984
+
1985
+ # Large generated files
1986
+ # generated/
1987
+
1988
+ # Vendor directories not in defaults
1989
+ # third_party/
1990
+
1991
+ # Project-specific build outputs
1992
+ # .output/
1993
+
1994
+ # Test fixtures that shouldn't be reviewed
1995
+ # fixtures/large-*.json
1996
+ """
1997
+ IGNORE_PROJECT.write_text(template)
1998
+ print(f"✓ Created {IGNORE_PROJECT}")
1999
+ print("\nEdit this file to add project-specific ignore patterns.")
2000
+ return 0
2001
+
2002
+
2003
+ def cmd_ignore_test(args: argparse.Namespace) -> int:
2004
+ """Test if a path would be ignored."""
2005
+ from crucible.ignore import clear_ignore_cache, load_ignore_spec
2006
+
2007
+ clear_ignore_cache()
2008
+ spec = load_ignore_spec()
2009
+
2010
+ path = args.path
2011
+ is_dir = Path(path).is_dir() if Path(path).exists() else path.endswith("/")
2012
+
2013
+ if spec.is_ignored(path, is_dir=is_dir):
2014
+ print(f"✓ {path} would be IGNORED")
2015
+ return 0
2016
+ else:
2017
+ print(f"✗ {path} would be INCLUDED in review")
2018
+ return 1
2019
+
2020
+
1913
2021
  # --- Main ---
1914
2022
 
1915
2023
 
@@ -2217,6 +2325,37 @@ def main() -> int:
2217
2325
  help="Show current configuration"
2218
2326
  )
2219
2327
 
2328
+ # === ignore command ===
2329
+ ignore_parser = subparsers.add_parser("ignore", help="Manage file ignore patterns")
2330
+ ignore_sub = ignore_parser.add_subparsers(dest="ignore_command")
2331
+
2332
+ # ignore show
2333
+ ignore_sub.add_parser(
2334
+ "show",
2335
+ help="Show active ignore patterns"
2336
+ )
2337
+
2338
+ # ignore init
2339
+ ignore_init_parser = ignore_sub.add_parser(
2340
+ "init",
2341
+ help="Create a project .crucibleignore file"
2342
+ )
2343
+ ignore_init_parser.add_argument(
2344
+ "--force", "-f",
2345
+ action="store_true",
2346
+ help="Overwrite existing file"
2347
+ )
2348
+
2349
+ # ignore test
2350
+ ignore_test_parser = ignore_sub.add_parser(
2351
+ "test",
2352
+ help="Test if a path would be ignored"
2353
+ )
2354
+ ignore_test_parser.add_argument(
2355
+ "path",
2356
+ help="Path to test"
2357
+ )
2358
+
2220
2359
  args = parser.parse_args()
2221
2360
 
2222
2361
  if args.command == "init":
@@ -2296,6 +2435,16 @@ def main() -> int:
2296
2435
  else:
2297
2436
  config_parser.print_help()
2298
2437
  return 0
2438
+ elif args.command == "ignore":
2439
+ if args.ignore_command == "show":
2440
+ return cmd_ignore_show(args)
2441
+ elif args.ignore_command == "init":
2442
+ return cmd_ignore_init(args)
2443
+ elif args.ignore_command == "test":
2444
+ return cmd_ignore_test(args)
2445
+ else:
2446
+ ignore_parser.print_help()
2447
+ return 0
2299
2448
  else:
2300
2449
  # Default help
2301
2450
  print("crucible - Code review orchestration\n")
@@ -0,0 +1,294 @@
1
+ """File ignore patterns for Crucible.
2
+
3
+ Supports .crucibleignore files with gitignore-style syntax.
4
+
5
+ Cascade priority (first found wins):
6
+ 1. .crucible/.crucibleignore (project)
7
+ 2. ~/.claude/crucible/.crucibleignore (user)
8
+ 3. Built-in defaults
9
+
10
+ Pattern syntax (subset of gitignore):
11
+ - Standard glob patterns: *.log, build/
12
+ - Directory markers: node_modules/ matches directory anywhere
13
+ - Negation: !important.log (include despite earlier exclusion)
14
+ - Comments: # this is a comment
15
+ - Blank lines are ignored
16
+ """
17
+
18
+ from dataclasses import dataclass, field
19
+ from fnmatch import fnmatch
20
+ from functools import lru_cache
21
+ from pathlib import Path
22
+
23
+ # Default patterns - always applied as base
24
+ DEFAULT_PATTERNS = [
25
+ # Version control
26
+ ".git/",
27
+ ".hg/",
28
+ ".svn/",
29
+ # Dependencies
30
+ "node_modules/",
31
+ "vendor/",
32
+ "bower_components/",
33
+ # Python
34
+ "__pycache__/",
35
+ "*.pyc",
36
+ "*.pyo",
37
+ ".venv/",
38
+ "venv/",
39
+ ".env/",
40
+ "env/",
41
+ ".tox/",
42
+ ".nox/",
43
+ ".pytest_cache/",
44
+ ".mypy_cache/",
45
+ ".ruff_cache/",
46
+ "*.egg-info/",
47
+ # Build outputs
48
+ "build/",
49
+ "dist/",
50
+ "out/",
51
+ "target/",
52
+ "_build/",
53
+ # IDE/Editor
54
+ ".idea/",
55
+ ".vscode/",
56
+ "*.swp",
57
+ "*.swo",
58
+ "*~",
59
+ ".DS_Store",
60
+ # Coverage/test artifacts
61
+ "coverage/",
62
+ ".coverage",
63
+ "htmlcov/",
64
+ ".nyc_output/",
65
+ # Logs
66
+ "*.log",
67
+ "logs/",
68
+ # Lock files (large, not useful to review)
69
+ "package-lock.json",
70
+ "yarn.lock",
71
+ "pnpm-lock.yaml",
72
+ "poetry.lock",
73
+ "Cargo.lock",
74
+ "composer.lock",
75
+ "Gemfile.lock",
76
+ # Other common excludes
77
+ ".next/",
78
+ ".nuxt/",
79
+ ".output/",
80
+ ".cache/",
81
+ ".parcel-cache/",
82
+ ".turbo/",
83
+ ]
84
+
85
+ # Ignore file locations (cascade priority)
86
+ IGNORE_PROJECT = Path(".crucible") / ".crucibleignore"
87
+ IGNORE_USER = Path.home() / ".claude" / "crucible" / ".crucibleignore"
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class IgnorePattern:
92
+ """A parsed ignore pattern."""
93
+
94
+ pattern: str
95
+ negated: bool = False
96
+ directory_only: bool = False
97
+
98
+ def matches(self, path: str, is_dir: bool = False) -> bool:
99
+ """Check if this pattern matches the given path.
100
+
101
+ Args:
102
+ path: Relative path to check (forward slashes)
103
+ is_dir: Whether the path is a directory
104
+
105
+ Returns:
106
+ True if pattern matches
107
+ """
108
+ # Normalize pattern for matching
109
+ pattern = self.pattern.rstrip("/")
110
+
111
+ # If pattern contains /, match against full path
112
+ if "/" in pattern:
113
+ if fnmatch(path, pattern) or fnmatch(path, f"**/{pattern}"):
114
+ # For directory-only patterns on a file, only match if path is under that dir
115
+ if self.directory_only and not is_dir:
116
+ # Check if pattern matches a parent directory
117
+ return f"{pattern}/" in path or path.startswith(f"{pattern}/")
118
+ return True
119
+ return False
120
+
121
+ # Otherwise, match against any path component
122
+ parts = path.split("/")
123
+ for i, part in enumerate(parts):
124
+ if fnmatch(part, pattern):
125
+ if self.directory_only:
126
+ # Directory-only patterns match if:
127
+ # 1. This component is not the last (so it's a directory in the path), OR
128
+ # 2. The path itself is a directory
129
+ if i < len(parts) - 1 or is_dir:
130
+ return True
131
+ else:
132
+ return True
133
+
134
+ return False
135
+
136
+
137
+ @dataclass
138
+ class IgnoreSpec:
139
+ """Collection of ignore patterns with match logic."""
140
+
141
+ patterns: list[IgnorePattern] = field(default_factory=list)
142
+ source: str = "default"
143
+
144
+ def is_ignored(self, path: str | Path, is_dir: bool = False) -> bool:
145
+ """Check if a path should be ignored.
146
+
147
+ Patterns are evaluated in order. Later patterns can override earlier ones.
148
+ Negated patterns (starting with !) un-ignore previously ignored paths.
149
+
150
+ Args:
151
+ path: Path to check (relative to project root)
152
+ is_dir: Whether the path is a directory
153
+
154
+ Returns:
155
+ True if the path should be ignored
156
+ """
157
+ if isinstance(path, Path):
158
+ path = str(path)
159
+
160
+ # Normalize path separators and strip ./ prefix (but not lone .)
161
+ path = path.replace("\\", "/")
162
+ while path.startswith("./"):
163
+ path = path[2:]
164
+
165
+ ignored = False
166
+ for pattern in self.patterns:
167
+ if pattern.matches(path, is_dir):
168
+ ignored = not pattern.negated
169
+
170
+ return ignored
171
+
172
+ def filter_paths(self, paths: list[Path], base: Path | None = None) -> list[Path]:
173
+ """Filter a list of paths, removing ignored ones.
174
+
175
+ Args:
176
+ paths: List of paths to filter
177
+ base: Base path for computing relative paths
178
+
179
+ Returns:
180
+ List of non-ignored paths
181
+ """
182
+ result = []
183
+ for path in paths:
184
+ rel_path = path.relative_to(base) if base else path
185
+ is_dir = path.is_dir()
186
+ if not self.is_ignored(str(rel_path), is_dir):
187
+ result.append(path)
188
+ return result
189
+
190
+
191
+ def parse_ignore_file(content: str) -> list[IgnorePattern]:
192
+ """Parse a .crucibleignore file into patterns.
193
+
194
+ Args:
195
+ content: File content
196
+
197
+ Returns:
198
+ List of parsed patterns
199
+ """
200
+ patterns = []
201
+
202
+ for line in content.splitlines():
203
+ # Strip whitespace
204
+ line = line.strip()
205
+
206
+ # Skip empty lines and comments
207
+ if not line or line.startswith("#"):
208
+ continue
209
+
210
+ # Check for negation
211
+ negated = False
212
+ if line.startswith("!"):
213
+ negated = True
214
+ line = line[1:]
215
+
216
+ # Check for directory-only marker
217
+ directory_only = line.endswith("/")
218
+
219
+ patterns.append(
220
+ IgnorePattern(
221
+ pattern=line,
222
+ negated=negated,
223
+ directory_only=directory_only,
224
+ )
225
+ )
226
+
227
+ return patterns
228
+
229
+
230
+ def _load_ignore_file(path: Path) -> list[IgnorePattern]:
231
+ """Load patterns from an ignore file."""
232
+ try:
233
+ content = path.read_text()
234
+ return parse_ignore_file(content)
235
+ except OSError:
236
+ return []
237
+
238
+
239
+ @lru_cache(maxsize=1)
240
+ def _get_default_patterns() -> list[IgnorePattern]:
241
+ """Get default patterns (cached)."""
242
+ return parse_ignore_file("\n".join(DEFAULT_PATTERNS))
243
+
244
+
245
+ def load_ignore_spec(project_root: Path | None = None) -> IgnoreSpec:
246
+ """Load ignore specification with cascade resolution.
247
+
248
+ Args:
249
+ project_root: Project root directory (defaults to cwd)
250
+
251
+ Returns:
252
+ IgnoreSpec with all applicable patterns
253
+ """
254
+ if project_root is None:
255
+ project_root = Path.cwd()
256
+
257
+ patterns: list[IgnorePattern] = []
258
+ source = "default"
259
+
260
+ # Start with defaults
261
+ patterns.extend(_get_default_patterns())
262
+
263
+ # Layer user patterns (if exists)
264
+ if IGNORE_USER.exists():
265
+ patterns.extend(_load_ignore_file(IGNORE_USER))
266
+ source = "user"
267
+
268
+ # Layer project patterns (highest priority)
269
+ project_ignore = project_root / IGNORE_PROJECT
270
+ if project_ignore.exists():
271
+ patterns.extend(_load_ignore_file(project_ignore))
272
+ source = "project"
273
+
274
+ return IgnoreSpec(patterns=patterns, source=source)
275
+
276
+
277
+ def should_ignore(path: str | Path, project_root: Path | None = None) -> bool:
278
+ """Convenience function to check if a path should be ignored.
279
+
280
+ Args:
281
+ path: Path to check
282
+ project_root: Project root for loading ignore spec
283
+
284
+ Returns:
285
+ True if path should be ignored
286
+ """
287
+ spec = load_ignore_spec(project_root)
288
+ is_dir = Path(path).is_dir() if isinstance(path, str) else path.is_dir()
289
+ return spec.is_ignored(path, is_dir)
290
+
291
+
292
+ def clear_ignore_cache() -> None:
293
+ """Clear the ignore pattern cache. Useful for testing."""
294
+ _get_default_patterns.cache_clear()
@@ -2,10 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  from collections import Counter
6
7
  from pathlib import Path
7
8
 
8
9
  from crucible.enforcement.models import BudgetState, ComplianceConfig
10
+ from crucible.ignore import load_ignore_spec
9
11
  from crucible.models import Domain, Severity, ToolFinding
10
12
  from crucible.tools.delegation import (
11
13
  delegate_bandit,
@@ -64,17 +66,16 @@ def detect_domain(path: str) -> tuple[Domain, list[str]]:
64
66
  # Scan files in directory (up to 1000 to avoid huge repos)
65
67
  file_count = 0
66
68
  max_files = 1000
67
- skip_dirs = {"node_modules", "__pycache__", "venv", ".venv", "dist", "build"}
69
+ ignore_spec = load_ignore_spec(p)
68
70
 
69
71
  for file_path in p.rglob("*"):
70
72
  if file_count >= max_files:
71
73
  break
72
74
  if not file_path.is_file():
73
75
  continue
74
- # Skip hidden files and common non-code directories
75
- if any(part.startswith(".") for part in file_path.parts):
76
- continue
77
- if any(part in skip_dirs for part in file_path.parts):
76
+ # Use .crucibleignore patterns
77
+ rel_path = file_path.relative_to(p)
78
+ if ignore_spec.is_ignored(str(rel_path), is_dir=False):
78
79
  continue
79
80
 
80
81
  domain, tags = detect_domain_for_file(str(file_path))
@@ -189,6 +190,46 @@ def deduplicate_findings(findings: list[ToolFinding]) -> list[ToolFinding]:
189
190
  return list(seen.values())
190
191
 
191
192
 
193
+ def filter_ignored_findings(
194
+ findings: list[ToolFinding],
195
+ base_path: str | Path | None = None,
196
+ ) -> list[ToolFinding]:
197
+ """Filter out findings from ignored paths.
198
+
199
+ Args:
200
+ findings: All findings from analysis
201
+ base_path: Base path for loading ignore spec (defaults to cwd)
202
+
203
+ Returns:
204
+ Findings that are not in ignored paths
205
+ """
206
+ if base_path is None:
207
+ base_path = Path.cwd()
208
+ elif isinstance(base_path, str):
209
+ base_path = Path(base_path)
210
+
211
+ spec = load_ignore_spec(base_path)
212
+ filtered: list[ToolFinding] = []
213
+
214
+ for finding in findings:
215
+ # Parse location: "path:line" or "path:line:col" or just "path"
216
+ parts = finding.location.split(":")
217
+ file_path = parts[0]
218
+
219
+ # Make path relative if it's absolute
220
+ try:
221
+ path_obj = Path(file_path)
222
+ if path_obj.is_absolute():
223
+ file_path = str(path_obj.relative_to(base_path))
224
+ except ValueError:
225
+ pass # Not under base_path, keep as-is
226
+
227
+ if not spec.is_ignored(file_path, is_dir=False):
228
+ filtered.append(finding)
229
+
230
+ return filtered
231
+
232
+
192
233
  def filter_findings_to_changes(
193
234
  findings: list[ToolFinding],
194
235
  context: GitContext,
@@ -330,7 +371,6 @@ def run_enforcement(
330
371
  Returns:
331
372
  (enforcement_findings, errors, assertions_checked, assertions_skipped, budget_state)
332
373
  """
333
- import os
334
374
 
335
375
  from crucible.enforcement.assertions import load_assertions
336
376
  from crucible.enforcement.compliance import run_llm_assertions, run_llm_assertions_batch
@@ -421,10 +461,23 @@ def run_enforcement(
421
461
 
422
462
  elif os.path.isdir(path):
423
463
  # Directory - collect all files for batch processing
424
- for root, _, files in os.walk(path):
464
+ ignore_spec = load_ignore_spec(Path(path))
465
+ for root, dirs, files in os.walk(path):
466
+ # Filter out ignored directories in-place (modifies os.walk behavior)
467
+ rel_root = os.path.relpath(root, path)
468
+ dirs[:] = [
469
+ d for d in dirs
470
+ if not ignore_spec.is_ignored(
471
+ os.path.join(rel_root, d) if rel_root != "." else d,
472
+ is_dir=True
473
+ )
474
+ ]
425
475
  for fname in files:
426
476
  fpath = os.path.join(root, fname)
427
477
  rel_path = os.path.relpath(fpath, path)
478
+ # Skip ignored files
479
+ if ignore_spec.is_ignored(rel_path, is_dir=False):
480
+ continue
428
481
  try:
429
482
  with open(fpath) as f:
430
483
  file_content = f.read()
@@ -18,6 +18,7 @@ from crucible.review.core import (
18
18
  deduplicate_findings,
19
19
  detect_domain,
20
20
  filter_findings_to_changes,
21
+ filter_ignored_findings,
21
22
  load_skills_and_knowledge,
22
23
  run_enforcement,
23
24
  run_static_analysis,
@@ -509,6 +510,14 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
509
510
  if not changed_files:
510
511
  return [TextContent(type="text", text="No files to analyze (only deletions).")]
511
512
 
513
+ # Filter out ignored files (.crucibleignore)
514
+ from crucible.ignore import load_ignore_spec
515
+ repo_path_for_ignore = get_repo_root(path if path else os.getcwd()).value
516
+ ignore_spec = load_ignore_spec(repo_path_for_ignore)
517
+ changed_files = [f for f in changed_files if not ignore_spec.is_ignored(f, is_dir=False)]
518
+ if not changed_files:
519
+ return [TextContent(type="text", text="No files to analyze (all changes are in ignored paths).")]
520
+
512
521
  elif not path:
513
522
  return [TextContent(type="text", text="Error: Either 'path' or 'mode' is required.")]
514
523
 
@@ -546,6 +555,10 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
546
555
  # Deduplicate findings
547
556
  all_findings = deduplicate_findings(all_findings)
548
557
 
558
+ # Filter out findings from ignored paths (.crucibleignore)
559
+ base_path = path if path else os.getcwd()
560
+ all_findings = filter_ignored_findings(all_findings, base_path)
561
+
549
562
  # Run pattern and LLM assertions
550
563
  enforcement_findings = []
551
564
  enforcement_errors: list[str] = []
@@ -0,0 +1,83 @@
1
+ ---
2
+ version: "1.0"
3
+ triggers: [cleanup, refactor, deprecate, dead-code, unused, tech-debt, maintenance]
4
+ always_run: false
5
+ always_run_for_domains: []
6
+ knowledge: []
7
+ ---
8
+
9
+ # Code Hygiene Engineer
10
+
11
+ You are reviewing code for cleanliness and maintainability. Your job is to identify dead code, deprecated patterns, stale task markers, and cleanup opportunities.
12
+
13
+ ## Key Questions
14
+
15
+ Ask yourself these questions about the code:
16
+
17
+ - Is this code still used? By what?
18
+ - Are these imports actually needed?
19
+ - Is this marked deprecated but still present?
20
+ - Are there task markers (FIXME/XXX) that reference completed work?
21
+ - Is there commented-out code that should be deleted?
22
+
23
+ ## Red Flags
24
+
25
+ Watch for these patterns:
26
+
27
+ - Functions/classes with no callers
28
+ - Imports that are never used
29
+ - `DEPRECATED` or `@deprecated` markers
30
+ - Stale task markers like `FIXME: remove` or `XXX: delete`
31
+ - Large blocks of commented-out code
32
+ - Feature flags for features that shipped long ago
33
+ - Backwards-compatibility shims that are no longer needed
34
+ - Variables assigned but never read
35
+ - Unreachable code after return/raise/break
36
+
37
+ ## Before Approving
38
+
39
+ Verify these criteria:
40
+
41
+ - [ ] No deprecated code introduced
42
+ - [ ] No unused imports added
43
+ - [ ] Task markers have actionable context (ticket/date/owner)
44
+ - [ ] Commented-out code removed or justified
45
+ - [ ] No dead code paths
46
+ - [ ] Backwards-compat shims have removal dates
47
+ - [ ] Feature flags have cleanup tickets
48
+
49
+ ## Cleanup Opportunities
50
+
51
+ When reviewing, note opportunities to:
52
+
53
+ - Remove unused parameters
54
+ - Delete deprecated functions after migration
55
+ - Clean up old feature flags
56
+ - Remove backwards-compat code after version bump
57
+ - Consolidate duplicate code
58
+ - Remove redundant comments
59
+
60
+ ## Output Format
61
+
62
+ Structure your hygiene review as:
63
+
64
+ ### Dead Code Found
65
+ List unused or unreachable code with confidence level.
66
+
67
+ ### Deprecation Issues
68
+ Code marked deprecated or using deprecated patterns.
69
+
70
+ ### Stale Task Markers
71
+ FIXME/XXX comments that appear outdated or reference completed work.
72
+
73
+ ### Cleanup Opportunities
74
+ Suggestions for improving code cleanliness.
75
+
76
+ ### Approval Status
77
+ - APPROVE: Code is clean
78
+ - REQUEST CHANGES: Dead code or deprecated patterns must be addressed
79
+ - COMMENT: Minor cleanup suggestions
80
+
81
+ ---
82
+
83
+ *Template. Adapt to your needs.*
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crucible-mcp
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Code review MCP server for Claude. Not affiliated with Atlassian.
5
5
  Author: be.nvy
6
6
  License-Expression: MIT
@@ -3,6 +3,7 @@ pyproject.toml
3
3
  src/crucible/__init__.py
4
4
  src/crucible/cli.py
5
5
  src/crucible/errors.py
6
+ src/crucible/ignore.py
6
7
  src/crucible/models.py
7
8
  src/crucible/server.py
8
9
  src/crucible/domain/__init__.py
@@ -41,6 +42,7 @@ src/crucible/skills/__init__.py
41
42
  src/crucible/skills/loader.py
42
43
  src/crucible/skills/accessibility-engineer/SKILL.md
43
44
  src/crucible/skills/backend-engineer/SKILL.md
45
+ src/crucible/skills/code-hygiene/SKILL.md
44
46
  src/crucible/skills/customer-success/SKILL.md
45
47
  src/crucible/skills/data-engineer/SKILL.md
46
48
  src/crucible/skills/devops-engineer/SKILL.md
@@ -74,6 +76,7 @@ tests/test_enforcement.py
74
76
  tests/test_full_review.py
75
77
  tests/test_git.py
76
78
  tests/test_hooks_cli.py
79
+ tests/test_ignore.py
77
80
  tests/test_integration.py
78
81
  tests/test_knowledge.py
79
82
  tests/test_precommit.py
@@ -0,0 +1,194 @@
1
+ """Tests for crucible.ignore module."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from crucible.ignore import (
8
+ DEFAULT_PATTERNS,
9
+ IgnorePattern,
10
+ IgnoreSpec,
11
+ clear_ignore_cache,
12
+ load_ignore_spec,
13
+ parse_ignore_file,
14
+ should_ignore,
15
+ )
16
+
17
+
18
+ class TestIgnorePattern:
19
+ """Tests for IgnorePattern matching."""
20
+
21
+ def test_simple_glob(self) -> None:
22
+ """Simple glob patterns match files."""
23
+ pattern = IgnorePattern(pattern="*.log")
24
+ assert pattern.matches("error.log", is_dir=False)
25
+ assert pattern.matches("logs/error.log", is_dir=False)
26
+ assert not pattern.matches("error.txt", is_dir=False)
27
+
28
+ def test_directory_pattern(self) -> None:
29
+ """Directory patterns match directories and files inside them."""
30
+ pattern = IgnorePattern(pattern="node_modules/", directory_only=True)
31
+ # Matches files inside the directory
32
+ assert pattern.matches("node_modules/foo.js", is_dir=False)
33
+ assert pattern.matches("src/node_modules/bar.js", is_dir=False)
34
+ # Matches the directory itself
35
+ assert pattern.matches("node_modules", is_dir=True)
36
+ # Does not match files with similar names
37
+ assert not pattern.matches("node_modules_backup.txt", is_dir=False)
38
+
39
+ def test_dot_directory(self) -> None:
40
+ """Directories starting with . are matched correctly."""
41
+ pattern = IgnorePattern(pattern=".next/", directory_only=True)
42
+ assert pattern.matches(".next/build.js", is_dir=False)
43
+ assert pattern.matches("apps/web/.next/cache.js", is_dir=False)
44
+ assert pattern.matches(".next", is_dir=True)
45
+
46
+ def test_hidden_files(self) -> None:
47
+ """Hidden files are matched."""
48
+ pattern = IgnorePattern(pattern=".DS_Store")
49
+ assert pattern.matches(".DS_Store", is_dir=False)
50
+ assert pattern.matches("src/.DS_Store", is_dir=False)
51
+
52
+ def test_nested_path(self) -> None:
53
+ """Nested path patterns match correctly."""
54
+ pattern = IgnorePattern(pattern="__pycache__/", directory_only=True)
55
+ assert pattern.matches("src/__pycache__/mod.pyc", is_dir=False)
56
+ assert pattern.matches("__pycache__/mod.pyc", is_dir=False)
57
+
58
+
59
+ class TestIgnoreSpec:
60
+ """Tests for IgnoreSpec collection."""
61
+
62
+ def test_is_ignored_basic(self) -> None:
63
+ """Basic ignore checking works."""
64
+ spec = IgnoreSpec(
65
+ patterns=[
66
+ IgnorePattern(pattern="*.log"),
67
+ IgnorePattern(pattern="node_modules/", directory_only=True),
68
+ ]
69
+ )
70
+ assert spec.is_ignored("error.log")
71
+ assert spec.is_ignored("node_modules/foo.js")
72
+ assert not spec.is_ignored("src/main.py")
73
+
74
+ def test_negation(self) -> None:
75
+ """Negated patterns un-ignore files."""
76
+ spec = IgnoreSpec(
77
+ patterns=[
78
+ IgnorePattern(pattern="*.log"),
79
+ IgnorePattern(pattern="important.log", negated=True),
80
+ ]
81
+ )
82
+ assert spec.is_ignored("error.log")
83
+ assert not spec.is_ignored("important.log")
84
+
85
+ def test_filter_paths(self) -> None:
86
+ """Filter paths removes ignored ones."""
87
+ spec = IgnoreSpec(
88
+ patterns=[IgnorePattern(pattern="*.pyc")],
89
+ )
90
+ base = Path("/project")
91
+ paths = [
92
+ Path("/project/main.py"),
93
+ Path("/project/__pycache__/main.pyc"),
94
+ Path("/project/util.py"),
95
+ ]
96
+ filtered = spec.filter_paths(paths, base)
97
+ assert len(filtered) == 2
98
+ assert Path("/project/main.py") in filtered
99
+ assert Path("/project/util.py") in filtered
100
+
101
+
102
+ class TestParseIgnoreFile:
103
+ """Tests for parsing .crucibleignore files."""
104
+
105
+ def test_parse_simple(self) -> None:
106
+ """Simple patterns are parsed."""
107
+ content = """
108
+ *.log
109
+ node_modules/
110
+ """
111
+ patterns = parse_ignore_file(content)
112
+ assert len(patterns) == 2
113
+ assert patterns[0].pattern == "*.log"
114
+ assert patterns[1].pattern == "node_modules/"
115
+ assert patterns[1].directory_only
116
+
117
+ def test_parse_comments(self) -> None:
118
+ """Comments and blank lines are ignored."""
119
+ content = """
120
+ # This is a comment
121
+ *.log
122
+
123
+ # Another comment
124
+ build/
125
+ """
126
+ patterns = parse_ignore_file(content)
127
+ assert len(patterns) == 2
128
+
129
+ def test_parse_negation(self) -> None:
130
+ """Negated patterns are parsed."""
131
+ content = """
132
+ *.log
133
+ !important.log
134
+ """
135
+ patterns = parse_ignore_file(content)
136
+ assert len(patterns) == 2
137
+ assert not patterns[0].negated
138
+ assert patterns[1].negated
139
+ assert patterns[1].pattern == "important.log"
140
+
141
+
142
+ class TestLoadIgnoreSpec:
143
+ """Tests for loading ignore specs."""
144
+
145
+ def test_default_patterns_included(self) -> None:
146
+ """Default patterns are always included."""
147
+ clear_ignore_cache()
148
+ spec = load_ignore_spec()
149
+ assert len(spec.patterns) >= len(DEFAULT_PATTERNS)
150
+ # Check some key defaults
151
+ assert spec.is_ignored("node_modules/foo.js")
152
+ assert spec.is_ignored(".git/config")
153
+ assert spec.is_ignored("__pycache__/mod.pyc")
154
+
155
+ def test_project_file_override(self, tmp_path: Path) -> None:
156
+ """Project .crucibleignore overrides defaults."""
157
+ clear_ignore_cache()
158
+ crucible_dir = tmp_path / ".crucible"
159
+ crucible_dir.mkdir()
160
+ ignore_file = crucible_dir / ".crucibleignore"
161
+ ignore_file.write_text("custom_ignore/\n")
162
+
163
+ spec = load_ignore_spec(tmp_path)
164
+ assert spec.source == "project"
165
+ assert spec.is_ignored("custom_ignore/file.txt")
166
+ # Defaults still apply
167
+ assert spec.is_ignored("node_modules/foo.js")
168
+
169
+
170
+ class TestDefaultPatterns:
171
+ """Tests for default ignore patterns."""
172
+
173
+ @pytest.mark.parametrize(
174
+ "path,expected",
175
+ [
176
+ ("node_modules/react/index.js", True),
177
+ (".next/build/manifest.json", True),
178
+ (".git/objects/abc123", True),
179
+ ("__pycache__/module.cpython-311.pyc", True),
180
+ (".venv/lib/python3.11/site.py", True),
181
+ ("dist/bundle.js", True),
182
+ ("build/output.o", True),
183
+ ("package-lock.json", True),
184
+ ("yarn.lock", True),
185
+ ("src/main.py", False),
186
+ ("components/Button.tsx", False),
187
+ ("contracts/Token.sol", False),
188
+ ],
189
+ )
190
+ def test_default_ignore_patterns(self, path: str, expected: bool) -> None:
191
+ """Default patterns ignore common non-source files."""
192
+ clear_ignore_cache()
193
+ spec = load_ignore_spec()
194
+ assert spec.is_ignored(path) == expected, f"{path} should be {'ignored' if expected else 'included'}"
@@ -1,4 +1,4 @@
1
- """Tests for bundled skills - validates all 18 skills have proper structure."""
1
+ """Tests for bundled skills - validates all 19 skills have proper structure."""
2
2
 
3
3
  import re
4
4
 
@@ -26,6 +26,7 @@ EXPECTED_SKILLS = {
26
26
  "mev-researcher",
27
27
  "formal-verification",
28
28
  "incident-responder",
29
+ "code-hygiene",
29
30
  }
30
31
 
31
32
 
@@ -38,13 +39,13 @@ class TestSkillsExist:
38
39
  for skill in EXPECTED_SKILLS:
39
40
  assert skill in actual, f"Missing skill: {skill}"
40
41
 
41
- def test_exactly_18_bundled_skills(self) -> None:
42
- """Should have exactly 18 bundled skills."""
42
+ def test_exactly_19_bundled_skills(self) -> None:
43
+ """Should have exactly 19 bundled skills."""
43
44
  bundled = set()
44
45
  for skill_dir in SKILLS_BUNDLED.iterdir():
45
46
  if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
46
47
  bundled.add(skill_dir.name)
47
- assert len(bundled) == 18, f"Expected 18 skills, got {len(bundled)}: {bundled}"
48
+ assert len(bundled) == 19, f"Expected 19 skills, got {len(bundled)}: {bundled}"
48
49
 
49
50
  def test_skills_directory_exists(self) -> None:
50
51
  """Skills bundled directory should exist."""
File without changes
File without changes