crucible-mcp 0.4.0__py3-none-any.whl → 0.5.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.
@@ -1,6 +1,6 @@
1
1
  """Data models for the enforcement module."""
2
2
 
3
- from dataclasses import dataclass
3
+ from dataclasses import dataclass, field
4
4
  from enum import Enum
5
5
  from typing import Literal
6
6
 
@@ -12,6 +12,14 @@ class AssertionType(Enum):
12
12
  LLM = "llm"
13
13
 
14
14
 
15
+ class OverflowBehavior(Enum):
16
+ """Behavior when token budget is exceeded."""
17
+
18
+ SKIP = "skip" # Skip remaining assertions silently
19
+ WARN = "warn" # Skip with warning
20
+ FAIL = "fail" # Fail the review
21
+
22
+
15
23
  class Priority(Enum):
16
24
  """Assertion priority levels for budget management."""
17
25
 
@@ -105,3 +113,65 @@ class EnforcementFinding:
105
113
  match_text: str | None = None
106
114
  suppressed: bool = False
107
115
  suppression_reason: str | None = None
116
+ source: Literal["pattern", "llm"] = "pattern"
117
+ llm_reasoning: str | None = None # LLM's explanation for the finding
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class ComplianceConfig:
122
+ """Configuration for LLM-based compliance checking."""
123
+
124
+ enabled: bool = True
125
+ model: str = "sonnet" # Default model (sonnet or opus)
126
+ token_budget: int = 10000 # 0 = unlimited
127
+ priority_order: tuple[str, ...] = ("critical", "high", "medium", "low")
128
+ overflow_behavior: OverflowBehavior = OverflowBehavior.WARN
129
+
130
+
131
+ @dataclass
132
+ class BudgetState:
133
+ """Mutable state for tracking token budget during compliance run."""
134
+
135
+ total_budget: int
136
+ tokens_used: int = 0
137
+ assertions_run: int = 0
138
+ assertions_skipped: int = 0
139
+ overflow_triggered: bool = False
140
+ skipped_assertions: list[str] = field(default_factory=list)
141
+
142
+ @property
143
+ def tokens_remaining(self) -> int:
144
+ """Tokens remaining in budget."""
145
+ if self.total_budget == 0:
146
+ return float("inf") # type: ignore[return-value]
147
+ return max(0, self.total_budget - self.tokens_used)
148
+
149
+ @property
150
+ def is_exhausted(self) -> bool:
151
+ """Whether budget is exhausted."""
152
+ if self.total_budget == 0:
153
+ return False
154
+ return self.tokens_used >= self.total_budget
155
+
156
+ def consume(self, tokens: int) -> None:
157
+ """Consume tokens from budget."""
158
+ self.tokens_used += tokens
159
+ self.assertions_run += 1
160
+
161
+ def skip(self, assertion_id: str) -> None:
162
+ """Record a skipped assertion."""
163
+ self.assertions_skipped += 1
164
+ self.skipped_assertions.append(assertion_id)
165
+ self.overflow_triggered = True
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class LLMAssertionResult:
170
+ """Result from running a single LLM assertion."""
171
+
172
+ assertion_id: str
173
+ passed: bool
174
+ findings: tuple["EnforcementFinding", ...]
175
+ tokens_used: int
176
+ model_used: str
177
+ error: str | None = None
crucible/review/core.py CHANGED
@@ -1,8 +1,11 @@
1
1
  """Core review functionality shared between CLI and MCP server."""
2
2
 
3
+ from __future__ import annotations
4
+
3
5
  from collections import Counter
4
6
  from pathlib import Path
5
7
 
8
+ from crucible.enforcement.models import BudgetState, ComplianceConfig
6
9
  from crucible.models import Domain, Severity, ToolFinding
7
10
  from crucible.tools.delegation import (
8
11
  delegate_bandit,
@@ -313,31 +316,42 @@ def run_enforcement(
313
316
  content: str | None = None,
314
317
  changed_files: list[str] | None = None,
315
318
  repo_root: str | None = None,
316
- ) -> tuple[list, list[str], int, int]:
317
- """Run pattern assertions.
319
+ compliance_config: ComplianceConfig | None = None,
320
+ ) -> tuple[list, list[str], int, int, BudgetState | None]:
321
+ """Run pattern and LLM assertions.
318
322
 
319
323
  Args:
320
324
  path: File or directory path
321
325
  content: File content (for single file mode)
322
326
  changed_files: List of changed files (for git mode)
323
327
  repo_root: Repository root path (for git mode)
328
+ compliance_config: Configuration for LLM compliance checking (optional)
324
329
 
325
330
  Returns:
326
- (enforcement_findings, errors, assertions_checked, assertions_skipped)
331
+ (enforcement_findings, errors, assertions_checked, assertions_skipped, budget_state)
327
332
  """
328
333
  import os
329
334
 
330
335
  from crucible.enforcement.assertions import load_assertions
336
+ from crucible.enforcement.compliance import run_llm_assertions, run_llm_assertions_batch
331
337
  from crucible.enforcement.models import EnforcementFinding
332
338
  from crucible.enforcement.patterns import run_pattern_assertions
333
339
 
334
340
  assertions, errors = load_assertions()
335
341
  if not assertions:
336
- return [], errors, 0, 0
342
+ return [], errors, 0, 0, None
337
343
 
338
344
  findings: list[EnforcementFinding] = []
339
345
  checked = 0
340
346
  skipped = 0
347
+ budget_state: BudgetState | None = None
348
+
349
+ # Default compliance config if not provided
350
+ if compliance_config is None:
351
+ compliance_config = ComplianceConfig()
352
+
353
+ # Collect files for batch LLM processing
354
+ files_for_llm: list[tuple[str, str]] = []
341
355
 
342
356
  if changed_files and repo_root:
343
357
  # Git mode: check each changed file
@@ -346,26 +360,67 @@ def run_enforcement(
346
360
  try:
347
361
  with open(full_path) as f:
348
362
  file_content = f.read()
363
+
364
+ # Run pattern assertions
349
365
  f_findings, c, s = run_pattern_assertions(file_path, file_content, assertions)
350
366
  findings.extend(f_findings)
351
367
  checked = max(checked, c)
352
368
  skipped = max(skipped, s)
369
+
370
+ # Collect for LLM processing
371
+ if compliance_config.enabled:
372
+ files_for_llm.append((file_path, file_content))
353
373
  except OSError:
354
374
  pass # File may have been deleted
375
+
376
+ # Run LLM assertions in batch
377
+ if files_for_llm and compliance_config.enabled:
378
+ llm_findings, budget_state, llm_errors = run_llm_assertions_batch(
379
+ files_for_llm, assertions, compliance_config
380
+ )
381
+ findings.extend(llm_findings)
382
+ errors.extend(llm_errors)
383
+ if budget_state:
384
+ skipped += budget_state.assertions_skipped
385
+
355
386
  elif content is not None:
356
387
  # Single file with provided content
357
388
  f_findings, checked, skipped = run_pattern_assertions(path, content, assertions)
358
389
  findings.extend(f_findings)
390
+
391
+ # Run LLM assertions
392
+ if compliance_config.enabled:
393
+ llm_findings, budget_state, llm_errors = run_llm_assertions(
394
+ path, content, assertions, compliance_config
395
+ )
396
+ findings.extend(llm_findings)
397
+ errors.extend(llm_errors)
398
+ if budget_state:
399
+ skipped += budget_state.assertions_skipped
400
+
359
401
  elif os.path.isfile(path):
360
402
  # Single file
361
403
  try:
362
404
  with open(path) as f:
363
405
  file_content = f.read()
364
- findings, checked, skipped = run_pattern_assertions(path, file_content, assertions)
406
+
407
+ p_findings, checked, skipped = run_pattern_assertions(path, file_content, assertions)
408
+ findings.extend(p_findings)
409
+
410
+ # Run LLM assertions
411
+ if compliance_config.enabled:
412
+ llm_findings, budget_state, llm_errors = run_llm_assertions(
413
+ path, file_content, assertions, compliance_config
414
+ )
415
+ findings.extend(llm_findings)
416
+ errors.extend(llm_errors)
417
+ if budget_state:
418
+ skipped += budget_state.assertions_skipped
365
419
  except OSError as e:
366
420
  errors.append(f"Failed to read {path}: {e}")
421
+
367
422
  elif os.path.isdir(path):
368
- # Directory
423
+ # Directory - collect all files for batch processing
369
424
  for root, _, files in os.walk(path):
370
425
  for fname in files:
371
426
  fpath = os.path.join(root, fname)
@@ -373,11 +428,27 @@ def run_enforcement(
373
428
  try:
374
429
  with open(fpath) as f:
375
430
  file_content = f.read()
431
+
432
+ # Run pattern assertions
376
433
  f_findings, c, s = run_pattern_assertions(rel_path, file_content, assertions)
377
434
  findings.extend(f_findings)
378
435
  checked = max(checked, c)
379
436
  skipped = max(skipped, s)
437
+
438
+ # Collect for LLM processing
439
+ if compliance_config.enabled:
440
+ files_for_llm.append((rel_path, file_content))
380
441
  except (OSError, UnicodeDecodeError):
381
442
  pass # Skip unreadable files
382
443
 
383
- return findings, errors, checked, skipped
444
+ # Run LLM assertions in batch
445
+ if files_for_llm and compliance_config.enabled:
446
+ llm_findings, budget_state, llm_errors = run_llm_assertions_batch(
447
+ files_for_llm, assertions, compliance_config
448
+ )
449
+ findings.extend(llm_findings)
450
+ errors.extend(llm_errors)
451
+ if budget_state:
452
+ skipped += budget_state.assertions_skipped
453
+
454
+ return findings, errors, checked, skipped, budget_state
crucible/server.py CHANGED
@@ -117,6 +117,20 @@ async def list_tools() -> list[Tool]:
117
117
  "description": "Run pattern assertions from .crucible/assertions/ (default: true).",
118
118
  "default": True,
119
119
  },
120
+ "compliance_enabled": {
121
+ "type": "boolean",
122
+ "description": "Enable LLM compliance assertions (default: true).",
123
+ "default": True,
124
+ },
125
+ "compliance_model": {
126
+ "type": "string",
127
+ "enum": ["sonnet", "opus", "haiku"],
128
+ "description": "Model for LLM compliance assertions (default: sonnet).",
129
+ },
130
+ "token_budget": {
131
+ "type": "integer",
132
+ "description": "Token budget for LLM assertions (0 = unlimited, default: 10000).",
133
+ },
120
134
  },
121
135
  },
122
136
  ),
@@ -318,6 +332,7 @@ def _format_review_output(
318
332
  enforcement_errors: list[str] | None = None,
319
333
  assertions_checked: int = 0,
320
334
  assertions_skipped: int = 0,
335
+ budget_state: Any = None,
321
336
  ) -> str:
322
337
  """Format unified review output."""
323
338
  parts: list[str] = ["# Code Review\n"]
@@ -392,9 +407,22 @@ def _format_review_output(
392
407
  active = [f for f in enforcement_findings if not f.suppressed]
393
408
  suppressed = [f for f in enforcement_findings if f.suppressed]
394
409
 
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")
410
+ # Separate pattern vs LLM findings
411
+ pattern_findings = [f for f in active if getattr(f, "source", "pattern") == "pattern"]
412
+ llm_findings = [f for f in active if getattr(f, "source", "pattern") == "llm"]
413
+
414
+ parts.append("## Enforcement Assertions\n")
415
+
416
+ # Summary line
417
+ summary_parts = []
418
+ if assertions_checked > 0:
419
+ summary_parts.append(f"Checked: {assertions_checked}")
420
+ if assertions_skipped > 0:
421
+ summary_parts.append(f"Skipped: {assertions_skipped}")
422
+ if budget_state and budget_state.tokens_used > 0:
423
+ summary_parts.append(f"LLM tokens: {budget_state.tokens_used}")
424
+ if summary_parts:
425
+ parts.append(f"*{', '.join(summary_parts)}*\n")
398
426
 
399
427
  if enforcement_errors:
400
428
  parts.append("**Errors:**")
@@ -402,22 +430,40 @@ def _format_review_output(
402
430
  parts.append(f"- {err}")
403
431
  parts.append("")
404
432
 
405
- if active:
406
- # Group by severity
433
+ # Pattern assertions
434
+ if pattern_findings:
435
+ parts.append("### Pattern Assertions\n")
407
436
  by_sev: dict[str, list] = {}
408
- for f in active:
437
+ for f in pattern_findings:
409
438
  by_sev.setdefault(f.severity.upper(), []).append(f)
410
439
 
411
440
  for sev in ["ERROR", "WARNING", "INFO"]:
412
441
  if sev in by_sev:
413
- parts.append(f"### {sev} ({len(by_sev[sev])})\n")
442
+ parts.append(f"#### {sev} ({len(by_sev[sev])})\n")
414
443
  for f in by_sev[sev]:
415
444
  parts.append(f"- **[{f.assertion_id}]** {f.message}")
416
445
  parts.append(f" - Location: `{f.location}`")
417
446
  if f.match_text:
418
447
  parts.append(f" - Match: `{f.match_text}`")
419
- else:
420
- parts.append("No pattern violations found.")
448
+
449
+ # LLM compliance assertions
450
+ if llm_findings:
451
+ parts.append("### LLM Compliance Assertions\n")
452
+ by_sev_llm: dict[str, list] = {}
453
+ for f in llm_findings:
454
+ by_sev_llm.setdefault(f.severity.upper(), []).append(f)
455
+
456
+ for sev in ["ERROR", "WARNING", "INFO"]:
457
+ if sev in by_sev_llm:
458
+ parts.append(f"#### {sev} ({len(by_sev_llm[sev])})\n")
459
+ for f in by_sev_llm[sev]:
460
+ parts.append(f"- **[{f.assertion_id}]** {f.message}")
461
+ parts.append(f" - Location: `{f.location}`")
462
+ if getattr(f, "llm_reasoning", None):
463
+ parts.append(f" - Reasoning: {f.llm_reasoning}")
464
+
465
+ if not pattern_findings and not llm_findings:
466
+ parts.append("No assertion violations found.")
421
467
 
422
468
  if suppressed:
423
469
  parts.append(f"\n*Suppressed: {len(suppressed)}*")
@@ -452,6 +498,8 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
452
498
  """Handle unified review tool."""
453
499
  import os
454
500
 
501
+ from crucible.enforcement.models import ComplianceConfig, OverflowBehavior
502
+
455
503
  path = arguments.get("path")
456
504
  mode = arguments.get("mode")
457
505
  base = arguments.get("base")
@@ -461,6 +509,18 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
461
509
  include_knowledge = arguments.get("include_knowledge", True)
462
510
  enforce = arguments.get("enforce", True)
463
511
 
512
+ # Build compliance config
513
+ compliance_enabled = arguments.get("compliance_enabled", True)
514
+ compliance_model = arguments.get("compliance_model", "sonnet")
515
+ token_budget = arguments.get("token_budget", 10000)
516
+
517
+ compliance_config = ComplianceConfig(
518
+ enabled=compliance_enabled,
519
+ model=compliance_model,
520
+ token_budget=token_budget,
521
+ overflow_behavior=OverflowBehavior.WARN,
522
+ )
523
+
464
524
  # Determine if this is path-based or git-based review
465
525
  git_context: GitContext | None = None
466
526
  changed_files: list[str] = []
@@ -544,21 +604,27 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
544
604
  # Deduplicate findings
545
605
  all_findings = deduplicate_findings(all_findings)
546
606
 
547
- # Run pattern assertions
607
+ # Run pattern and LLM assertions
548
608
  enforcement_findings = []
549
609
  enforcement_errors: list[str] = []
550
610
  assertions_checked = 0
551
611
  assertions_skipped = 0
612
+ budget_state = None
552
613
 
553
614
  if enforce:
554
615
  if git_context:
555
616
  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)
617
+ enforcement_findings, enforcement_errors, assertions_checked, assertions_skipped, budget_state = (
618
+ run_enforcement(
619
+ path or "",
620
+ changed_files=changed_files,
621
+ repo_root=repo_path,
622
+ compliance_config=compliance_config,
623
+ )
558
624
  )
559
625
  elif path:
560
- enforcement_findings, enforcement_errors, assertions_checked, assertions_skipped = (
561
- run_enforcement(path)
626
+ enforcement_findings, enforcement_errors, assertions_checked, assertions_skipped, budget_state = (
627
+ run_enforcement(path, compliance_config=compliance_config)
562
628
  )
563
629
 
564
630
  # Compute severity summary
@@ -598,6 +664,7 @@ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
598
664
  enforcement_errors if enforce else None,
599
665
  assertions_checked,
600
666
  assertions_skipped,
667
+ budget_state,
601
668
  )
602
669
 
603
670
  return [TextContent(type="text", text=output)]
crucible/tools/git.py CHANGED
@@ -42,11 +42,17 @@ class GitContext:
42
42
 
43
43
 
44
44
  def is_git_repo(path: str | Path) -> bool:
45
- """Check if the path is inside a git repository."""
45
+ """Check if the path is inside a git repository.
46
+
47
+ Works with both files and directories.
48
+ """
49
+ path = Path(path)
50
+ # Use parent directory if path is a file
51
+ check_dir = path.parent if path.is_file() else path
46
52
  try:
47
53
  result = subprocess.run(
48
54
  ["git", "rev-parse", "--git-dir"],
49
- cwd=str(path),
55
+ cwd=str(check_dir),
50
56
  capture_output=True,
51
57
  text=True,
52
58
  timeout=5,
@@ -57,14 +63,21 @@ def is_git_repo(path: str | Path) -> bool:
57
63
 
58
64
 
59
65
  def get_repo_root(path: str | Path) -> Result[str, str]:
60
- """Get the root directory of the git repository."""
66
+ """Get the root directory of the git repository.
67
+
68
+ Works with both files and directories.
69
+ """
61
70
  if not shutil.which("git"):
62
71
  return err("git not found")
63
72
 
73
+ path = Path(path)
74
+ # Use parent directory if path is a file
75
+ check_dir = path.parent if path.is_file() else path
76
+
64
77
  try:
65
78
  result = subprocess.run(
66
79
  ["git", "rev-parse", "--show-toplevel"],
67
- cwd=str(path),
80
+ cwd=str(check_dir),
68
81
  capture_output=True,
69
82
  text=True,
70
83
  timeout=5,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crucible-mcp
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Code review MCP server for Claude. Not affiliated with Atlassian.
5
5
  Author: be.nvy
6
6
  License-Expression: MIT
@@ -9,6 +9,7 @@ Requires-Python: >=3.11
9
9
  Description-Content-Type: text/markdown
10
10
  Requires-Dist: mcp>=1.0.0
11
11
  Requires-Dist: pyyaml>=6.0
12
+ Requires-Dist: anthropic>=0.40.0
12
13
  Provides-Extra: dev
13
14
  Requires-Dist: pytest>=8.0; extra == "dev"
14
15
  Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
@@ -1,28 +1,30 @@
1
1
  crucible/__init__.py,sha256=M4v_CsJVOdiAAPgmd54mxkkbnes8e5ifMznDuOJhzzY,77
2
- crucible/cli.py,sha256=8rmzsh92h1kFSCFBGfd50pKWTdd7r2c3W_klk9c5JnY,60527
2
+ crucible/cli.py,sha256=NwYbIUAW4zfPUIOw1tsASsV0rtEjiillvdO1tslkH_Y,74932
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=rzSi1sfuu1o5CpjMUgxFjJB6zjra9-M9WFmmelmORWA,43817
5
+ crucible/server.py,sha256=oyfDl5-Ih3eoXBGDZ0ud7H3m2v0tn-Mc7mo19Bd-d00,46756
6
6
  crucible/domain/__init__.py,sha256=2fsoB5wH2Pl3vtGRt4voYOSZ04-zLoW8pNq6nvzVMgU,118
7
7
  crucible/domain/detection.py,sha256=TNeLB_VQgS1AsT5BKDf_tIpGa47THrFoRXwU4u54VB0,1797
8
8
  crucible/enforcement/__init__.py,sha256=FOaGSrE1SWFPxBJ1L5VoDhQDmlJgRXXs_iiI20wHf2Q,867
9
9
  crucible/enforcement/assertions.py,sha256=ay5QvJIr_YaqWYbrJNhbouafJOiy4ZhwiX7E9VAY3s4,8166
10
- crucible/enforcement/models.py,sha256=aIuxjqZfACpabT2lB1J3LKVzkcc1RTdbr7mAuBEAreU,2435
10
+ crucible/enforcement/budget.py,sha256=-wFTlVY80c3-eJhvmlWrdlS8LB8E25aMnhtYpwR38sQ,4706
11
+ crucible/enforcement/compliance.py,sha256=tkK-lSC5OMGOgt5xXY_APVSr-qHGB9kE8dUtoTF_n5w,14889
12
+ crucible/enforcement/models.py,sha256=dEcPiUL6JEOBtxWOgKd_PZnsW_nUIaFsx18L70fM59M,4574
11
13
  crucible/enforcement/patterns.py,sha256=hE4Z-JJ9OBruSFPBDxw_aNaSJbyUPD2SWCEwA1KzDmI,9720
12
14
  crucible/hooks/__init__.py,sha256=k5oEWhTJKEQi-QWBfTbp1p6HaKg55_wVCBVD5pZzdqw,271
13
15
  crucible/hooks/precommit.py,sha256=OAwvjEACopcrTmWmZMO0S8TqZkvFY_392pJBFCHGSaQ,21561
14
16
  crucible/knowledge/__init__.py,sha256=unb7kyO1MtB3Zt-TGx_O8LE79KyrGrNHoFFHgUWUvGU,40
15
17
  crucible/knowledge/loader.py,sha256=DD4gqU6xkssaWvEkbymMOu6YtHab7YLEk-tU9cPTeaE,6666
16
18
  crucible/review/__init__.py,sha256=Ssva6Yaqcc44AqL9OUMjxypu5R1PPkrmLGk6OKtP15w,547
17
- crucible/review/core.py,sha256=Yl7NFFrUtzwjonovhv6sP2fDoplosAjxLvi5vASx08o,12671
19
+ crucible/review/core.py,sha256=OdJd3kIY0MNkwJ-oUnopogeJ02vtXegPmGqLC1UvKmE,15482
18
20
  crucible/skills/__init__.py,sha256=L3heXWF0T3aR9yYLFphs1LNlkxAFSPkPuRFMH-S1taI,495
19
21
  crucible/skills/loader.py,sha256=iC0_V1s6CIse5NXyFGtpLbON8xDxYh8xXmHH7hAX5O0,8642
20
22
  crucible/synthesis/__init__.py,sha256=CYrkZG4bdAjp8XdOh1smfKscd3YU5lZlaDLGwLE9c-0,46
21
23
  crucible/tools/__init__.py,sha256=gFRThTk1E-fHzpe8bB5rtBG6Z6G-ysPzjVEHfKGbEYU,400
22
24
  crucible/tools/delegation.py,sha256=_x1y76No3qkmGjjROVvMx1pSKKwU59aRu5R-r07lVFU,12871
23
- crucible/tools/git.py,sha256=EmxRUt0jSFLa_mm_2Czt5rHdiFC0YK9IpaPDfRwlXVo,10051
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,,
25
+ crucible/tools/git.py,sha256=7-aJCesoQe3ZEBFcRxHBhY8RpZrBlNtHSns__RqiG04,10406
26
+ crucible_mcp-0.5.0.dist-info/METADATA,sha256=d0ui9gJweDBJox6HvWiW6LvWAfaB9tOjXZJhF6eksuE,5201
27
+ crucible_mcp-0.5.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
28
+ crucible_mcp-0.5.0.dist-info/entry_points.txt,sha256=18BZaH1OlFSFYtKuHq0Z8yYX8Wmx7Ikfqay-P00ZX3Q,83
29
+ crucible_mcp-0.5.0.dist-info/top_level.txt,sha256=4hzuFgqbFPOO-WiU_DYxTm8VYIxTXh7Wlp0gRcWR0Cs,9
30
+ crucible_mcp-0.5.0.dist-info/RECORD,,