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.
- crucible/cli.py +425 -12
- crucible/enforcement/budget.py +179 -0
- crucible/enforcement/compliance.py +486 -0
- crucible/enforcement/models.py +71 -1
- crucible/review/core.py +78 -7
- crucible/server.py +81 -14
- crucible/tools/git.py +17 -4
- {crucible_mcp-0.4.0.dist-info → crucible_mcp-0.5.0.dist-info}/METADATA +2 -1
- {crucible_mcp-0.4.0.dist-info → crucible_mcp-0.5.0.dist-info}/RECORD +12 -10
- {crucible_mcp-0.4.0.dist-info → crucible_mcp-0.5.0.dist-info}/WHEEL +0 -0
- {crucible_mcp-0.4.0.dist-info → crucible_mcp-0.5.0.dist-info}/entry_points.txt +0 -0
- {crucible_mcp-0.4.0.dist-info → crucible_mcp-0.5.0.dist-info}/top_level.txt +0 -0
crucible/enforcement/models.py
CHANGED
|
@@ -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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
406
|
-
|
|
433
|
+
# Pattern assertions
|
|
434
|
+
if pattern_findings:
|
|
435
|
+
parts.append("### Pattern Assertions\n")
|
|
407
436
|
by_sev: dict[str, list] = {}
|
|
408
|
-
for f in
|
|
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"
|
|
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
|
-
|
|
420
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
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=
|
|
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=
|
|
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/
|
|
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=
|
|
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=
|
|
24
|
-
crucible_mcp-0.
|
|
25
|
-
crucible_mcp-0.
|
|
26
|
-
crucible_mcp-0.
|
|
27
|
-
crucible_mcp-0.
|
|
28
|
-
crucible_mcp-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|