crucible-mcp 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

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.
crucible/server.py CHANGED
@@ -13,6 +13,15 @@ from crucible.knowledge.loader import (
13
13
  load_principles,
14
14
  )
15
15
  from crucible.models import Domain, FullReviewResult, Severity, ToolFinding
16
+ from crucible.review.core import (
17
+ compute_severity_counts,
18
+ deduplicate_findings,
19
+ detect_domain,
20
+ filter_findings_to_changes,
21
+ load_skills_and_knowledge,
22
+ run_enforcement,
23
+ run_static_analysis,
24
+ )
16
25
  from crucible.skills import get_knowledge_for_skills, load_skill, match_skills_for_domain
17
26
  from crucible.tools.delegation import (
18
27
  check_all_tools,
@@ -61,38 +70,6 @@ def _format_findings(findings: list[ToolFinding]) -> str:
61
70
  return "\n".join(parts) if parts else "No findings."
62
71
 
63
72
 
64
- def _deduplicate_findings(findings: list[ToolFinding]) -> list[ToolFinding]:
65
- """Deduplicate findings by location and message.
66
-
67
- When multiple tools report the same issue at the same location,
68
- keep only the highest severity finding.
69
- """
70
- # Group by (location, normalized_message)
71
- seen: dict[tuple[str, str], ToolFinding] = {}
72
-
73
- for f in findings:
74
- # Normalize the message for comparison (lowercase, strip whitespace)
75
- norm_msg = f.message.lower().strip()
76
- key = (f.location, norm_msg)
77
-
78
- if key not in seen:
79
- seen[key] = f
80
- else:
81
- # Keep the higher severity finding
82
- existing = seen[key]
83
- severity_order = [
84
- Severity.CRITICAL,
85
- Severity.HIGH,
86
- Severity.MEDIUM,
87
- Severity.LOW,
88
- Severity.INFO,
89
- ]
90
- if severity_order.index(f.severity) < severity_order.index(existing.severity):
91
- seen[key] = f
92
-
93
- return list(seen.values())
94
-
95
-
96
73
  @server.list_tools() # type: ignore[misc]
97
74
  async def list_tools() -> list[Tool]:
98
75
  """List available tools."""
@@ -135,6 +112,11 @@ async def list_tools() -> list[Tool]:
135
112
  "description": "Load knowledge files (default: true). Set false for quick analysis only.",
136
113
  "default": True,
137
114
  },
115
+ "enforce": {
116
+ "type": "boolean",
117
+ "description": "Run pattern assertions from .crucible/assertions/ (default: true).",
118
+ "default": True,
119
+ },
138
120
  },
139
121
  },
140
122
  ),
@@ -321,105 +303,6 @@ async def list_tools() -> list[Tool]:
321
303
  ]
322
304
 
323
305
 
324
- def _run_static_analysis(
325
- path: str,
326
- domain: Domain,
327
- domain_tags: list[str],
328
- ) -> tuple[list[ToolFinding], list[str]]:
329
- """Run static analysis tools based on domain.
330
-
331
- Returns (findings, tool_errors).
332
- """
333
- # Select tools based on domain
334
- if domain == Domain.SMART_CONTRACT:
335
- tools = ["slither", "semgrep"]
336
- elif domain == Domain.BACKEND and "python" in domain_tags:
337
- tools = ["ruff", "bandit", "semgrep"]
338
- elif domain == Domain.FRONTEND:
339
- tools = ["semgrep"]
340
- else:
341
- tools = ["semgrep"]
342
-
343
- all_findings: list[ToolFinding] = []
344
- tool_errors: list[str] = []
345
-
346
- if "semgrep" in tools:
347
- config = get_semgrep_config(domain)
348
- result = delegate_semgrep(path, config)
349
- if result.is_ok:
350
- all_findings.extend(result.value)
351
- elif result.is_err:
352
- tool_errors.append(f"semgrep: {result.error}")
353
-
354
- if "ruff" in tools:
355
- result = delegate_ruff(path)
356
- if result.is_ok:
357
- all_findings.extend(result.value)
358
- elif result.is_err:
359
- tool_errors.append(f"ruff: {result.error}")
360
-
361
- if "slither" in tools:
362
- result = delegate_slither(path)
363
- if result.is_ok:
364
- all_findings.extend(result.value)
365
- elif result.is_err:
366
- tool_errors.append(f"slither: {result.error}")
367
-
368
- if "bandit" in tools:
369
- result = delegate_bandit(path)
370
- if result.is_ok:
371
- all_findings.extend(result.value)
372
- elif result.is_err:
373
- tool_errors.append(f"bandit: {result.error}")
374
-
375
- return all_findings, tool_errors
376
-
377
-
378
- def _load_skills_and_knowledge(
379
- domain: Domain,
380
- domain_tags: list[str],
381
- skills_override: list[str] | None = None,
382
- ) -> tuple[list[tuple[str, list[str]]], dict[str, str], set[str], dict[str, str]]:
383
- """Load matched skills and linked knowledge.
384
-
385
- Returns (matched_skills, skill_content, knowledge_files, knowledge_content).
386
- """
387
- from crucible.knowledge.loader import load_knowledge_file
388
- from crucible.skills.loader import (
389
- get_knowledge_for_skills,
390
- load_skill,
391
- match_skills_for_domain,
392
- )
393
-
394
- matched_skills = match_skills_for_domain(domain, domain_tags, skills_override)
395
- skill_names = [name for name, _ in matched_skills]
396
-
397
- # Load skill content
398
- skill_content: dict[str, str] = {}
399
- for skill_name, _ in matched_skills:
400
- result = load_skill(skill_name)
401
- if result.is_ok:
402
- _, content = result.value
403
- # Extract content after frontmatter
404
- if "\n---\n" in content:
405
- skill_content[skill_name] = content.split("\n---\n", 1)[1].strip()
406
- else:
407
- skill_content[skill_name] = content
408
-
409
- # Load knowledge from skills + custom project/user knowledge
410
- knowledge_files = get_knowledge_for_skills(skill_names)
411
- custom_knowledge = get_custom_knowledge_files()
412
- knowledge_files = knowledge_files | custom_knowledge
413
-
414
- knowledge_content: dict[str, str] = {}
415
- for filename in knowledge_files:
416
- result = load_knowledge_file(filename)
417
- if result.is_ok:
418
- knowledge_content[filename] = result.value
419
-
420
- return matched_skills, skill_content, knowledge_files, knowledge_content
421
-
422
-
423
306
  def _format_review_output(
424
307
  path: str | None,
425
308
  git_context: GitContext | None,
@@ -431,6 +314,10 @@ def _format_review_output(
431
314
  skill_content: dict[str, str] | None,
432
315
  knowledge_files: set[str] | None,
433
316
  knowledge_content: dict[str, str] | None,
317
+ enforcement_findings: list | None = None,
318
+ enforcement_errors: list[str] | None = None,
319
+ assertions_checked: int = 0,
320
+ assertions_skipped: int = 0,
434
321
  ) -> str:
435
322
  """Format unified review output."""
436
323
  parts: list[str] = ["# Code Review\n"]
@@ -500,6 +387,46 @@ def _format_review_output(
500
387
  parts.append("No issues found.")
501
388
  parts.append("")
502
389
 
390
+ # Enforcement assertions
391
+ if enforcement_findings is not None:
392
+ active = [f for f in enforcement_findings if not f.suppressed]
393
+ suppressed = [f for f in enforcement_findings if f.suppressed]
394
+
395
+ parts.append("## Pattern Assertions\n")
396
+ if assertions_checked > 0 or assertions_skipped > 0:
397
+ parts.append(f"*Checked: {assertions_checked}, Skipped (LLM): {assertions_skipped}*\n")
398
+
399
+ if enforcement_errors:
400
+ parts.append("**Errors:**")
401
+ for err in enforcement_errors:
402
+ parts.append(f"- {err}")
403
+ parts.append("")
404
+
405
+ if active:
406
+ # Group by severity
407
+ by_sev: dict[str, list] = {}
408
+ for f in active:
409
+ by_sev.setdefault(f.severity.upper(), []).append(f)
410
+
411
+ for sev in ["ERROR", "WARNING", "INFO"]:
412
+ if sev in by_sev:
413
+ parts.append(f"### {sev} ({len(by_sev[sev])})\n")
414
+ for f in by_sev[sev]:
415
+ parts.append(f"- **[{f.assertion_id}]** {f.message}")
416
+ parts.append(f" - Location: `{f.location}`")
417
+ if f.match_text:
418
+ parts.append(f" - Match: `{f.match_text}`")
419
+ else:
420
+ parts.append("No pattern violations found.")
421
+
422
+ if suppressed:
423
+ parts.append(f"\n*Suppressed: {len(suppressed)}*")
424
+ for f in suppressed:
425
+ reason = f" ({f.suppression_reason})" if f.suppression_reason else ""
426
+ parts.append(f"- {f.assertion_id}: {f.location}{reason}")
427
+
428
+ parts.append("")
429
+
503
430
  # Review checklists from skills
504
431
  if skill_content:
505
432
  parts.append("---\n")
@@ -532,6 +459,7 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
532
459
  skills_override = arguments.get("skills")
533
460
  include_skills = arguments.get("include_skills", True)
534
461
  include_knowledge = arguments.get("include_knowledge", True)
462
+ enforce = arguments.get("enforce", True)
535
463
 
536
464
  # Determine if this is path-based or git-based review
537
465
  git_context: GitContext | None = None
@@ -593,34 +521,48 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
593
521
  repo_path = get_repo_root(path if path else os.getcwd()).value
594
522
  for file_path in changed_files:
595
523
  full_path = f"{repo_path}/{file_path}"
596
- domain, domain_tags = _detect_domain(file_path)
524
+ domain, domain_tags = detect_domain(file_path)
597
525
  domains_detected.add(domain)
598
526
  all_domain_tags.update(domain_tags)
599
527
 
600
- findings, errors = _run_static_analysis(full_path, domain, domain_tags)
528
+ findings, errors = run_static_analysis(full_path, domain, domain_tags)
601
529
  all_findings.extend(findings)
602
530
  tool_errors.extend([f"{e} ({file_path})" for e in errors])
603
531
 
604
532
  # Filter findings to changed lines
605
- all_findings = _filter_findings_to_changes(all_findings, git_context, include_context)
533
+ all_findings = filter_findings_to_changes(all_findings, git_context, include_context)
606
534
  else:
607
535
  # Path mode: analyze the path directly
608
- domain, domain_tags = _detect_domain(path)
536
+ domain, domain_tags = detect_domain(path)
609
537
  domains_detected.add(domain)
610
538
  all_domain_tags.update(domain_tags)
611
539
 
612
- findings, errors = _run_static_analysis(path, domain, domain_tags)
540
+ findings, errors = run_static_analysis(path, domain, domain_tags)
613
541
  all_findings.extend(findings)
614
542
  tool_errors.extend(errors)
615
543
 
616
544
  # Deduplicate findings
617
- all_findings = _deduplicate_findings(all_findings)
545
+ all_findings = deduplicate_findings(all_findings)
546
+
547
+ # Run pattern assertions
548
+ enforcement_findings = []
549
+ enforcement_errors: list[str] = []
550
+ assertions_checked = 0
551
+ assertions_skipped = 0
552
+
553
+ if enforce:
554
+ if git_context:
555
+ repo_path = get_repo_root(path if path else os.getcwd()).value
556
+ enforcement_findings, enforcement_errors, assertions_checked, assertions_skipped = (
557
+ run_enforcement(path or "", changed_files=changed_files, repo_root=repo_path)
558
+ )
559
+ elif path:
560
+ enforcement_findings, enforcement_errors, assertions_checked, assertions_skipped = (
561
+ run_enforcement(path)
562
+ )
618
563
 
619
564
  # Compute severity summary
620
- severity_counts: dict[str, int] = {}
621
- for f in all_findings:
622
- sev = f.severity.value
623
- severity_counts[sev] = severity_counts.get(sev, 0) + 1
565
+ severity_counts = compute_severity_counts(all_findings)
624
566
 
625
567
  # Load skills and knowledge
626
568
  matched_skills: list[tuple[str, list[str]]] | None = None
@@ -630,7 +572,7 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
630
572
 
631
573
  if include_skills or include_knowledge:
632
574
  primary_domain = next(iter(domains_detected)) if domains_detected else Domain.UNKNOWN
633
- matched, s_content, k_files, k_content = _load_skills_and_knowledge(
575
+ matched, s_content, k_files, k_content = load_skills_and_knowledge(
634
576
  primary_domain, list(all_domain_tags), skills_override
635
577
  )
636
578
  if include_skills:
@@ -652,6 +594,10 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
652
594
  skill_content,
653
595
  knowledge_files,
654
596
  knowledge_content,
597
+ enforcement_findings if enforce else None,
598
+ enforcement_errors if enforce else None,
599
+ assertions_checked,
600
+ assertions_skipped,
655
601
  )
656
602
 
657
603
  return [TextContent(type="text", text=output)]
@@ -773,89 +719,13 @@ def _handle_check_tools(arguments: dict[str, Any]) -> list[TextContent]:
773
719
  return [TextContent(type="text", text="\n".join(parts))]
774
720
 
775
721
 
776
- def _detect_domain_for_file(path: str) -> tuple[Domain, list[str]]:
777
- """Detect domain from a single file path.
778
-
779
- Returns (domain, list of domain tags for skill matching).
780
- """
781
- if path.endswith(".sol"):
782
- return Domain.SMART_CONTRACT, ["solidity", "smart_contract", "web3"]
783
- elif path.endswith(".vy"):
784
- return Domain.SMART_CONTRACT, ["vyper", "smart_contract", "web3"]
785
- elif path.endswith(".py"):
786
- return Domain.BACKEND, ["python", "backend"]
787
- elif path.endswith((".ts", ".tsx")):
788
- return Domain.FRONTEND, ["typescript", "frontend"]
789
- elif path.endswith((".js", ".jsx")):
790
- return Domain.FRONTEND, ["javascript", "frontend"]
791
- elif path.endswith(".go"):
792
- return Domain.BACKEND, ["go", "backend"]
793
- elif path.endswith(".rs"):
794
- return Domain.BACKEND, ["rust", "backend"]
795
- elif path.endswith((".tf", ".yaml", ".yml")):
796
- return Domain.INFRASTRUCTURE, ["infrastructure", "devops"]
797
- else:
798
- return Domain.UNKNOWN, []
799
-
800
-
801
- def _detect_domain(path: str) -> tuple[Domain, list[str]]:
802
- """Detect domain from file or directory path.
803
-
804
- For directories, scans contained files and aggregates domains.
805
- Returns (primary_domain, list of all domain tags).
806
- """
807
- from collections import Counter
808
- from pathlib import Path
809
-
810
- p = Path(path)
811
-
812
- # Single file - use direct detection
813
- if p.is_file():
814
- return _detect_domain_for_file(path)
815
-
816
- # Directory - scan and aggregate
817
- if not p.is_dir():
818
- return Domain.UNKNOWN, ["unknown"]
819
-
820
- domain_counts: Counter[Domain] = Counter()
821
- all_tags: set[str] = set()
822
-
823
- # Scan files in directory (up to 1000 to avoid huge repos)
824
- file_count = 0
825
- max_files = 1000
826
-
827
- for file_path in p.rglob("*"):
828
- if file_count >= max_files:
829
- break
830
- if not file_path.is_file():
831
- continue
832
- # Skip hidden files and common non-code directories
833
- if any(part.startswith(".") for part in file_path.parts):
834
- continue
835
- if any(part in ("node_modules", "__pycache__", "venv", ".venv", "dist", "build") for part in file_path.parts):
836
- continue
837
-
838
- domain, tags = _detect_domain_for_file(str(file_path))
839
- if domain != Domain.UNKNOWN:
840
- domain_counts[domain] += 1
841
- all_tags.update(tags)
842
- file_count += 1
843
-
844
- # Return most common domain, or UNKNOWN if none found
845
- if not domain_counts:
846
- return Domain.UNKNOWN, ["unknown"]
847
-
848
- primary_domain = domain_counts.most_common(1)[0][0]
849
- return primary_domain, sorted(all_tags) if all_tags else ["unknown"]
850
-
851
-
852
722
  def _handle_quick_review(arguments: dict[str, Any]) -> list[TextContent]:
853
723
  """Handle quick_review tool - returns findings with domain metadata."""
854
724
  path = arguments.get("path", "")
855
725
  tools = arguments.get("tools")
856
726
 
857
727
  # Internal domain detection
858
- domain, domain_tags = _detect_domain(path)
728
+ domain, domain_tags = detect_domain(path)
859
729
 
860
730
  # Select tools based on domain
861
731
  if domain == Domain.SMART_CONTRACT:
@@ -908,7 +778,7 @@ def _handle_quick_review(arguments: dict[str, Any]) -> list[TextContent]:
908
778
  tool_results.append(f"## Bandit\nError: {result.error}")
909
779
 
910
780
  # Deduplicate findings
911
- all_findings = _deduplicate_findings(all_findings)
781
+ all_findings = deduplicate_findings(all_findings)
912
782
 
913
783
  # Compute severity summary
914
784
  severity_counts: dict[str, int] = {}
@@ -927,60 +797,6 @@ def _handle_quick_review(arguments: dict[str, Any]) -> list[TextContent]:
927
797
  return [TextContent(type="text", text="\n".join(output_parts))]
928
798
 
929
799
 
930
- def _filter_findings_to_changes(
931
- findings: list[ToolFinding],
932
- context: GitContext,
933
- include_context: bool = False,
934
- ) -> list[ToolFinding]:
935
- """Filter findings to only those in changed lines."""
936
- # Build a lookup of file -> changed line ranges
937
- changed_ranges: dict[str, list[tuple[int, int]]] = {}
938
- for change in context.changes:
939
- if change.status == "D":
940
- continue # Skip deleted files
941
- ranges = [(r.start, r.end) for r in change.added_lines]
942
- changed_ranges[change.path] = ranges
943
-
944
- context_lines = 5 if include_context else 0
945
- filtered: list[ToolFinding] = []
946
-
947
- for finding in findings:
948
- # Parse location: "path:line" or "path:line:col"
949
- parts = finding.location.split(":")
950
- if len(parts) < 2:
951
- continue
952
-
953
- file_path = parts[0]
954
- try:
955
- line_num = int(parts[1])
956
- except ValueError:
957
- continue
958
-
959
- # Check if file is in changes
960
- # Handle both absolute and relative paths
961
- matching_file = None
962
- for changed_file in changed_ranges:
963
- if file_path.endswith(changed_file) or changed_file.endswith(file_path):
964
- matching_file = changed_file
965
- break
966
-
967
- if not matching_file:
968
- continue
969
-
970
- # Check if line is in changed ranges
971
- ranges = changed_ranges[matching_file]
972
- in_range = False
973
- for start, end in ranges:
974
- if start - context_lines <= line_num <= end + context_lines:
975
- in_range = True
976
- break
977
-
978
- if in_range:
979
- filtered.append(finding)
980
-
981
- return filtered
982
-
983
-
984
800
  def _format_change_review(
985
801
  context: GitContext,
986
802
  findings: list[ToolFinding],
@@ -1136,7 +952,7 @@ def _handle_review_changes(arguments: dict[str, Any]) -> list[TextContent]:
1136
952
  full_path = f"{repo_path}/{file_path}"
1137
953
 
1138
954
  # Detect domain for this file
1139
- domain, domain_tags = _detect_domain(file_path)
955
+ domain, domain_tags = detect_domain(file_path)
1140
956
  domains_detected.add(domain)
1141
957
  all_domain_tags.update(domain_tags)
1142
958
 
@@ -1181,10 +997,10 @@ def _handle_review_changes(arguments: dict[str, Any]) -> list[TextContent]:
1181
997
  tool_errors.append(f"bandit ({file_path}): {result.error}")
1182
998
 
1183
999
  # Filter findings to changed lines
1184
- filtered_findings = _filter_findings_to_changes(all_findings, context, include_context)
1000
+ filtered_findings = filter_findings_to_changes(all_findings, context, include_context)
1185
1001
 
1186
1002
  # Deduplicate findings
1187
- filtered_findings = _deduplicate_findings(filtered_findings)
1003
+ filtered_findings = deduplicate_findings(filtered_findings)
1188
1004
 
1189
1005
  # Compute severity summary
1190
1006
  severity_counts: dict[str, int] = {}
@@ -1235,56 +1051,20 @@ def _handle_review_changes(arguments: dict[str, Any]) -> list[TextContent]:
1235
1051
 
1236
1052
 
1237
1053
  def _handle_full_review(arguments: dict[str, Any]) -> list[TextContent]:
1238
- """Handle full_review tool - comprehensive code review."""
1054
+ """Handle full_review tool - comprehensive code review.
1055
+
1056
+ DEPRECATED: Use _handle_review with path parameter instead.
1057
+ """
1058
+ from crucible.review.core import run_static_analysis
1059
+
1239
1060
  path = arguments.get("path", "")
1240
1061
  skills_override = arguments.get("skills")
1241
- # include_sage is accepted but not yet implemented
1242
- # _ = arguments.get("include_sage", True)
1243
1062
 
1244
1063
  # 1. Detect domain
1245
- domain, domain_tags = _detect_domain(path)
1246
-
1247
- # 2. Run static analysis (reuse quick_review logic)
1248
- if domain == Domain.SMART_CONTRACT:
1249
- default_tools = ["slither", "semgrep"]
1250
- elif domain == Domain.BACKEND and "python" in domain_tags:
1251
- default_tools = ["ruff", "bandit", "semgrep"]
1252
- elif domain == Domain.FRONTEND:
1253
- default_tools = ["semgrep"]
1254
- else:
1255
- default_tools = ["semgrep"]
1256
-
1257
- all_findings: list[ToolFinding] = []
1258
- tool_errors: list[str] = []
1064
+ domain, domain_tags = detect_domain(path)
1259
1065
 
1260
- if "semgrep" in default_tools:
1261
- config = get_semgrep_config(domain)
1262
- result = delegate_semgrep(path, config)
1263
- if result.is_ok:
1264
- all_findings.extend(result.value)
1265
- elif result.is_err:
1266
- tool_errors.append(f"semgrep: {result.error}")
1267
-
1268
- if "ruff" in default_tools:
1269
- result = delegate_ruff(path)
1270
- if result.is_ok:
1271
- all_findings.extend(result.value)
1272
- elif result.is_err:
1273
- tool_errors.append(f"ruff: {result.error}")
1274
-
1275
- if "slither" in default_tools:
1276
- result = delegate_slither(path)
1277
- if result.is_ok:
1278
- all_findings.extend(result.value)
1279
- elif result.is_err:
1280
- tool_errors.append(f"slither: {result.error}")
1281
-
1282
- if "bandit" in default_tools:
1283
- result = delegate_bandit(path)
1284
- if result.is_ok:
1285
- all_findings.extend(result.value)
1286
- elif result.is_err:
1287
- tool_errors.append(f"bandit: {result.error}")
1066
+ # 2. Run static analysis using shared core function
1067
+ all_findings, tool_errors = run_static_analysis(path, domain, domain_tags)
1288
1068
 
1289
1069
  # 3. Match applicable skills
1290
1070
  matched_skills = match_skills_for_domain(domain, domain_tags, skills_override)
@@ -1318,13 +1098,10 @@ def _handle_full_review(arguments: dict[str, Any]) -> list[TextContent]:
1318
1098
  )
1319
1099
 
1320
1100
  # 7. Deduplicate findings
1321
- all_findings = _deduplicate_findings(all_findings)
1101
+ all_findings = deduplicate_findings(all_findings)
1322
1102
 
1323
1103
  # 8. Compute severity summary
1324
- severity_counts: dict[str, int] = {}
1325
- for f in all_findings:
1326
- sev = f.severity.value
1327
- severity_counts[sev] = severity_counts.get(sev, 0) + 1
1104
+ severity_counts = compute_severity_counts(all_findings)
1328
1105
 
1329
1106
  # 8. Build result
1330
1107
  review_result = FullReviewResult(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crucible-mcp
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Code review MCP server for Claude. Not affiliated with Atlassian.
5
5
  Author: be.nvy
6
6
  License-Expression: MIT
@@ -17,7 +17,9 @@ Requires-Dist: ruff>=0.3; extra == "dev"
17
17
 
18
18
  # Crucible
19
19
 
20
- Load your coding patterns into Claude Code.
20
+ **Your team's standards, applied by Claude, every time.**
21
+
22
+ Claude without context applies generic best practices. Crucible loads *your* patterns—so Claude reviews code the way your team would, not the way the internet would.
21
23
 
22
24
  ```
23
25
  ├── Personas: Domain-specific thinking (how to approach problems)
@@ -26,7 +28,12 @@ Load your coding patterns into Claude Code.
26
28
  └── Context-aware: Loads relevant skills based on what you're working on
27
29
  ```
28
30
 
29
- **Personas for domains. Knowledge for patterns. All customizable.**
31
+ **Why Crucible?**
32
+ - **Consistency** — Same checklist every time, for every engineer, every session
33
+ - **Automation** — Runs in CI and pre-commit, not just interactively
34
+ - **Institutional knowledge** — Your senior engineer's mental checklist, in the repo
35
+ - **Your context** — Security fundamentals plus *your* auth patterns, *your* conventions, *your* definition of "done"
36
+ - **Cost efficiency** — Filter with free tools first, LLM only on what needs judgment
30
37
 
31
38
  > Not affiliated with Atlassian's Crucible.
32
39
 
@@ -1,22 +1,28 @@
1
- crucible/__init__.py,sha256=ZLZQWKmjTHaeeDijcOl3xmaEgoI2W3a8FCFwcieZGv0,77
2
- crucible/cli.py,sha256=eNLB4_GdO4rump_lKDjopXoKjbdr3e4hmrNleTrq_CE,54042
1
+ crucible/__init__.py,sha256=M4v_CsJVOdiAAPgmd54mxkkbnes8e5ifMznDuOJhzzY,77
2
+ crucible/cli.py,sha256=8rmzsh92h1kFSCFBGfd50pKWTdd7r2c3W_klk9c5JnY,60527
3
3
  crucible/errors.py,sha256=HrX_yvJEhXJoKodXGo_iY9wqx2J3ONYy0a_LbrVC5As,819
4
4
  crucible/models.py,sha256=jaxbiPc1E7bJxKPLadZe1dbSJdq-WINsxjveeSNNqeg,2066
5
- crucible/server.py,sha256=jIkcjiQYf9DZRDUw_e9ajoI3PxvTybpwJ9m3S_-kgZE,50768
5
+ crucible/server.py,sha256=rzSi1sfuu1o5CpjMUgxFjJB6zjra9-M9WFmmelmORWA,43817
6
6
  crucible/domain/__init__.py,sha256=2fsoB5wH2Pl3vtGRt4voYOSZ04-zLoW8pNq6nvzVMgU,118
7
7
  crucible/domain/detection.py,sha256=TNeLB_VQgS1AsT5BKDf_tIpGa47THrFoRXwU4u54VB0,1797
8
+ crucible/enforcement/__init__.py,sha256=FOaGSrE1SWFPxBJ1L5VoDhQDmlJgRXXs_iiI20wHf2Q,867
9
+ crucible/enforcement/assertions.py,sha256=ay5QvJIr_YaqWYbrJNhbouafJOiy4ZhwiX7E9VAY3s4,8166
10
+ crucible/enforcement/models.py,sha256=aIuxjqZfACpabT2lB1J3LKVzkcc1RTdbr7mAuBEAreU,2435
11
+ crucible/enforcement/patterns.py,sha256=hE4Z-JJ9OBruSFPBDxw_aNaSJbyUPD2SWCEwA1KzDmI,9720
8
12
  crucible/hooks/__init__.py,sha256=k5oEWhTJKEQi-QWBfTbp1p6HaKg55_wVCBVD5pZzdqw,271
9
13
  crucible/hooks/precommit.py,sha256=OAwvjEACopcrTmWmZMO0S8TqZkvFY_392pJBFCHGSaQ,21561
10
14
  crucible/knowledge/__init__.py,sha256=unb7kyO1MtB3Zt-TGx_O8LE79KyrGrNHoFFHgUWUvGU,40
11
15
  crucible/knowledge/loader.py,sha256=DD4gqU6xkssaWvEkbymMOu6YtHab7YLEk-tU9cPTeaE,6666
16
+ crucible/review/__init__.py,sha256=Ssva6Yaqcc44AqL9OUMjxypu5R1PPkrmLGk6OKtP15w,547
17
+ crucible/review/core.py,sha256=Yl7NFFrUtzwjonovhv6sP2fDoplosAjxLvi5vASx08o,12671
12
18
  crucible/skills/__init__.py,sha256=L3heXWF0T3aR9yYLFphs1LNlkxAFSPkPuRFMH-S1taI,495
13
19
  crucible/skills/loader.py,sha256=iC0_V1s6CIse5NXyFGtpLbON8xDxYh8xXmHH7hAX5O0,8642
14
20
  crucible/synthesis/__init__.py,sha256=CYrkZG4bdAjp8XdOh1smfKscd3YU5lZlaDLGwLE9c-0,46
15
21
  crucible/tools/__init__.py,sha256=gFRThTk1E-fHzpe8bB5rtBG6Z6G-ysPzjVEHfKGbEYU,400
16
22
  crucible/tools/delegation.py,sha256=_x1y76No3qkmGjjROVvMx1pSKKwU59aRu5R-r07lVFU,12871
17
23
  crucible/tools/git.py,sha256=EmxRUt0jSFLa_mm_2Czt5rHdiFC0YK9IpaPDfRwlXVo,10051
18
- crucible_mcp-0.3.0.dist-info/METADATA,sha256=t0Vyhm0Wlxbw6_Y17eYE-g-3doWUEjaHUZKnRCbrPHg,4586
19
- crucible_mcp-0.3.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
20
- crucible_mcp-0.3.0.dist-info/entry_points.txt,sha256=18BZaH1OlFSFYtKuHq0Z8yYX8Wmx7Ikfqay-P00ZX3Q,83
21
- crucible_mcp-0.3.0.dist-info/top_level.txt,sha256=4hzuFgqbFPOO-WiU_DYxTm8VYIxTXh7Wlp0gRcWR0Cs,9
22
- crucible_mcp-0.3.0.dist-info/RECORD,,
24
+ crucible_mcp-0.4.0.dist-info/METADATA,sha256=hmhKr9GR0M9x0qV-edCMibj8lFrgKb4Bgv-Kx-cJoSY,5168
25
+ crucible_mcp-0.4.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
26
+ crucible_mcp-0.4.0.dist-info/entry_points.txt,sha256=18BZaH1OlFSFYtKuHq0Z8yYX8Wmx7Ikfqay-P00ZX3Q,83
27
+ crucible_mcp-0.4.0.dist-info/top_level.txt,sha256=4hzuFgqbFPOO-WiU_DYxTm8VYIxTXh7Wlp0gRcWR0Cs,9
28
+ crucible_mcp-0.4.0.dist-info/RECORD,,