crucible-mcp 0.2.0__py3-none-any.whl → 0.3.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 CHANGED
@@ -652,11 +652,13 @@ def cmd_review(args: argparse.Namespace) -> int:
652
652
 
653
653
  # Track domains detected for per-domain threshold checking
654
654
  domains_detected: set[Domain] = set()
655
+ all_domain_tags: set[str] = set()
655
656
 
656
657
  for file_path in changed_files:
657
658
  full_path = f"{repo_path}/{file_path}"
658
659
  domain, domain_tags = detect_domain(file_path)
659
660
  domains_detected.add(domain)
661
+ all_domain_tags.update(domain_tags)
660
662
 
661
663
  # Select tools based on domain
662
664
  if domain == Domain.SMART_CONTRACT:
@@ -727,6 +729,37 @@ def cmd_review(args: argparse.Namespace) -> int:
727
729
 
728
730
  filtered_findings = deduplicate_findings(filtered_findings)
729
731
 
732
+ # Match skills and load knowledge based on detected domains
733
+ from crucible.knowledge.loader import load_knowledge_file
734
+ from crucible.skills.loader import (
735
+ get_knowledge_for_skills,
736
+ load_skill,
737
+ match_skills_for_domain,
738
+ )
739
+
740
+ # Use first domain for primary matching (most files determine this)
741
+ primary_domain = next(iter(domains_detected)) if domains_detected else Domain.UNKNOWN
742
+ matched_skills = match_skills_for_domain(
743
+ primary_domain, list(all_domain_tags), override=None
744
+ )
745
+
746
+ # Load skill content
747
+ skill_names = [name for name, _ in matched_skills]
748
+ skill_content: dict[str, str] = {}
749
+ for skill_name, _triggers in matched_skills:
750
+ result = load_skill(skill_name)
751
+ if result.is_ok:
752
+ _, content = result.value
753
+ skill_content[skill_name] = content
754
+
755
+ # Load linked knowledge
756
+ knowledge_files = get_knowledge_for_skills(skill_names)
757
+ knowledge_content: dict[str, str] = {}
758
+ for filename in knowledge_files:
759
+ result = load_knowledge_file(filename)
760
+ if result.is_ok:
761
+ knowledge_content[filename] = result.value
762
+
730
763
  # Compute severity summary
731
764
  severity_counts: dict[str, int] = {}
732
765
  for f in filtered_findings:
@@ -767,6 +800,8 @@ def cmd_review(args: argparse.Namespace) -> int:
767
800
  "mode": mode,
768
801
  "files_changed": len(changed_files),
769
802
  "domains_detected": [d.value for d in domains_detected],
803
+ "skills_matched": dict(matched_skills),
804
+ "knowledge_loaded": list(knowledge_files),
770
805
  "findings": [
771
806
  {
772
807
  "tool": f.tool,
@@ -817,6 +852,19 @@ def cmd_review(args: argparse.Namespace) -> int:
817
852
  print(f"- `{c.path}` ({status_char})")
818
853
  print()
819
854
 
855
+ # Skills matched
856
+ if matched_skills:
857
+ print("## Applicable Skills\n")
858
+ for skill_name, triggers in matched_skills:
859
+ print(f"- **{skill_name}**: matched on {', '.join(triggers)}")
860
+ print()
861
+
862
+ # Knowledge loaded
863
+ if knowledge_files:
864
+ print("## Knowledge Loaded\n")
865
+ print(f"Files: {', '.join(sorted(knowledge_files))}")
866
+ print()
867
+
820
868
  # Findings by severity
821
869
  if filtered_findings:
822
870
  print("## Findings\n")
@@ -849,6 +897,23 @@ def cmd_review(args: argparse.Namespace) -> int:
849
897
  print(f"- {error}")
850
898
  print()
851
899
 
900
+ # Review checklists from skills
901
+ if skill_content:
902
+ print("## Review Checklists\n")
903
+ for skill_name, content in skill_content.items():
904
+ print(f"### {skill_name}\n")
905
+ # Print the skill content (already markdown)
906
+ print(content)
907
+ print()
908
+
909
+ # Knowledge reference
910
+ if knowledge_content:
911
+ print("## Principles Reference\n")
912
+ for filename, content in sorted(knowledge_content.items()):
913
+ print(f"### {filename}\n")
914
+ print(content)
915
+ print()
916
+
852
917
  # Result
853
918
  print("## Result\n")
854
919
  if effective_threshold:
crucible/server.py CHANGED
@@ -97,9 +97,50 @@ def _deduplicate_findings(findings: list[ToolFinding]) -> list[ToolFinding]:
97
97
  async def list_tools() -> list[Tool]:
98
98
  """List available tools."""
99
99
  return [
100
+ Tool(
101
+ name="review",
102
+ description="Unified code review tool. Supports path-based review OR git-aware review. Runs static analysis, matches skills, loads knowledge.",
103
+ inputSchema={
104
+ "type": "object",
105
+ "properties": {
106
+ "path": {
107
+ "type": "string",
108
+ "description": "File or directory path to review. If not provided, uses git mode.",
109
+ },
110
+ "mode": {
111
+ "type": "string",
112
+ "enum": ["staged", "unstaged", "branch", "commits"],
113
+ "description": "Git mode: staged (about to commit), unstaged (working dir), branch (PR diff), commits (recent N)",
114
+ },
115
+ "base": {
116
+ "type": "string",
117
+ "description": "Base branch for 'branch' mode (default: main) or commit count for 'commits' mode (default: 1)",
118
+ },
119
+ "include_context": {
120
+ "type": "boolean",
121
+ "description": "For git modes: include findings near (within 5 lines of) changes (default: false)",
122
+ },
123
+ "skills": {
124
+ "type": "array",
125
+ "items": {"type": "string"},
126
+ "description": "Override skill selection (default: auto-detect based on domain)",
127
+ },
128
+ "include_skills": {
129
+ "type": "boolean",
130
+ "description": "Load skills and checklists (default: true). Set false for quick analysis only.",
131
+ "default": True,
132
+ },
133
+ "include_knowledge": {
134
+ "type": "boolean",
135
+ "description": "Load knowledge files (default: true). Set false for quick analysis only.",
136
+ "default": True,
137
+ },
138
+ },
139
+ },
140
+ ),
100
141
  Tool(
101
142
  name="quick_review",
102
- description="Run static analysis tools on code. Returns findings with domain metadata for skill selection.",
143
+ description="[DEPRECATED: use review(path, include_skills=false)] Run static analysis only.",
103
144
  inputSchema={
104
145
  "type": "object",
105
146
  "properties": {
@@ -116,6 +157,57 @@ async def list_tools() -> list[Tool]:
116
157
  "required": ["path"],
117
158
  },
118
159
  ),
160
+ Tool(
161
+ name="full_review",
162
+ description="[DEPRECATED: use review(path)] Comprehensive code review with skills and knowledge.",
163
+ inputSchema={
164
+ "type": "object",
165
+ "properties": {
166
+ "path": {
167
+ "type": "string",
168
+ "description": "File or directory path to review",
169
+ },
170
+ "skills": {
171
+ "type": "array",
172
+ "items": {"type": "string"},
173
+ "description": "Override skill selection (default: auto-detect based on domain)",
174
+ },
175
+ "include_sage": {
176
+ "type": "boolean",
177
+ "description": "Include Sage knowledge recall (not yet implemented)",
178
+ "default": True,
179
+ },
180
+ },
181
+ "required": ["path"],
182
+ },
183
+ ),
184
+ Tool(
185
+ name="review_changes",
186
+ description="[DEPRECATED: use review(mode='staged')] Review git changes.",
187
+ inputSchema={
188
+ "type": "object",
189
+ "properties": {
190
+ "mode": {
191
+ "type": "string",
192
+ "enum": ["staged", "unstaged", "branch", "commits"],
193
+ "description": "What changes to review",
194
+ },
195
+ "base": {
196
+ "type": "string",
197
+ "description": "Base branch for 'branch' mode or commit count for 'commits' mode",
198
+ },
199
+ "path": {
200
+ "type": "string",
201
+ "description": "Repository path (default: current directory)",
202
+ },
203
+ "include_context": {
204
+ "type": "boolean",
205
+ "description": "Include findings near changes (default: false)",
206
+ },
207
+ },
208
+ "required": ["mode"],
209
+ },
210
+ ),
119
211
  Tool(
120
212
  name="get_principles",
121
213
  description="Load engineering principles by topic",
@@ -203,57 +295,6 @@ async def list_tools() -> list[Tool]:
203
295
  "properties": {},
204
296
  },
205
297
  ),
206
- Tool(
207
- name="review_changes",
208
- description="Review git changes (staged, unstaged, branch diff, commits). Runs analysis on changed files and filters findings to changed lines only.",
209
- inputSchema={
210
- "type": "object",
211
- "properties": {
212
- "mode": {
213
- "type": "string",
214
- "enum": ["staged", "unstaged", "branch", "commits"],
215
- "description": "What changes to review: staged (about to commit), unstaged (working dir), branch (PR diff vs base), commits (recent N commits)",
216
- },
217
- "base": {
218
- "type": "string",
219
- "description": "Base branch for 'branch' mode (default: main) or commit count for 'commits' mode (default: 1)",
220
- },
221
- "path": {
222
- "type": "string",
223
- "description": "Repository path (default: current directory)",
224
- },
225
- "include_context": {
226
- "type": "boolean",
227
- "description": "Include findings near (within 5 lines of) changes, not just in changed lines (default: false)",
228
- },
229
- },
230
- "required": ["mode"],
231
- },
232
- ),
233
- Tool(
234
- name="full_review",
235
- description="Comprehensive code review: runs static analysis, matches applicable skills based on domain, loads linked knowledge. Returns unified report for synthesis.",
236
- inputSchema={
237
- "type": "object",
238
- "properties": {
239
- "path": {
240
- "type": "string",
241
- "description": "File or directory path to review",
242
- },
243
- "skills": {
244
- "type": "array",
245
- "items": {"type": "string"},
246
- "description": "Override skill selection (default: auto-detect based on domain)",
247
- },
248
- "include_sage": {
249
- "type": "boolean",
250
- "description": "Include Sage knowledge recall (not yet implemented)",
251
- "default": True,
252
- },
253
- },
254
- "required": ["path"],
255
- },
256
- ),
257
298
  Tool(
258
299
  name="load_knowledge",
259
300
  description="Load knowledge/principles files without running static analysis. Useful for getting guidance on patterns, best practices, or domain-specific knowledge. Automatically includes project and user knowledge files.",
@@ -280,6 +321,342 @@ async def list_tools() -> list[Tool]:
280
321
  ]
281
322
 
282
323
 
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
+ def _format_review_output(
424
+ path: str | None,
425
+ git_context: GitContext | None,
426
+ domains: list[str],
427
+ severity_counts: dict[str, int],
428
+ findings: list[ToolFinding],
429
+ tool_errors: list[str],
430
+ matched_skills: list[tuple[str, list[str]]] | None,
431
+ skill_content: dict[str, str] | None,
432
+ knowledge_files: set[str] | None,
433
+ knowledge_content: dict[str, str] | None,
434
+ ) -> str:
435
+ """Format unified review output."""
436
+ parts: list[str] = ["# Code Review\n"]
437
+
438
+ # Header based on mode
439
+ if git_context:
440
+ parts.append(f"**Mode:** {git_context.mode}")
441
+ if git_context.base_ref:
442
+ parts.append(f"**Base:** {git_context.base_ref}")
443
+ elif path:
444
+ parts.append(f"**Path:** `{path}`")
445
+
446
+ parts.append(f"**Domains:** {', '.join(domains)}")
447
+ parts.append(f"**Severity summary:** {severity_counts or 'No findings'}\n")
448
+
449
+ # Files changed (git mode)
450
+ if git_context and git_context.changes:
451
+ added = [c for c in git_context.changes if c.status == "A"]
452
+ modified = [c for c in git_context.changes if c.status == "M"]
453
+ deleted = [c for c in git_context.changes if c.status == "D"]
454
+ renamed = [c for c in git_context.changes if c.status == "R"]
455
+
456
+ total = len(git_context.changes)
457
+ parts.append(f"## Files Changed ({total})")
458
+ for c in added:
459
+ parts.append(f"- `+` {c.path}")
460
+ for c in modified:
461
+ parts.append(f"- `~` {c.path}")
462
+ for c in renamed:
463
+ parts.append(f"- `R` {c.old_path} -> {c.path}")
464
+ for c in deleted:
465
+ parts.append(f"- `-` {c.path}")
466
+ parts.append("")
467
+
468
+ # Commit messages
469
+ if git_context.commit_messages:
470
+ parts.append("## Commits")
471
+ for msg in git_context.commit_messages:
472
+ parts.append(f"- {msg}")
473
+ parts.append("")
474
+
475
+ # Tool errors
476
+ if tool_errors:
477
+ parts.append("## Tool Errors\n")
478
+ for error in tool_errors:
479
+ parts.append(f"- {error}")
480
+ parts.append("")
481
+
482
+ # Applicable skills
483
+ if matched_skills:
484
+ parts.append("## Applicable Skills\n")
485
+ for skill_name, triggers in matched_skills:
486
+ parts.append(f"- **{skill_name}**: matched on {', '.join(triggers)}")
487
+ parts.append("")
488
+
489
+ # Knowledge loaded
490
+ if knowledge_files:
491
+ parts.append("## Knowledge Loaded\n")
492
+ parts.append(f"Files: {', '.join(sorted(knowledge_files))}")
493
+ parts.append("")
494
+
495
+ # Findings
496
+ parts.append("## Static Analysis Findings\n")
497
+ if findings:
498
+ parts.append(_format_findings(findings))
499
+ else:
500
+ parts.append("No issues found.")
501
+ parts.append("")
502
+
503
+ # Review checklists from skills
504
+ if skill_content:
505
+ parts.append("---\n")
506
+ parts.append("## Review Checklists\n")
507
+ for skill_name, content in skill_content.items():
508
+ parts.append(f"### {skill_name}\n")
509
+ parts.append(content)
510
+ parts.append("")
511
+
512
+ # Knowledge reference
513
+ if knowledge_content:
514
+ parts.append("---\n")
515
+ parts.append("## Principles Reference\n")
516
+ for filename, content in sorted(knowledge_content.items()):
517
+ parts.append(f"### {filename}\n")
518
+ parts.append(content)
519
+ parts.append("")
520
+
521
+ return "\n".join(parts)
522
+
523
+
524
+ def _handle_review(arguments: dict[str, Any]) -> list[TextContent]:
525
+ """Handle unified review tool."""
526
+ import os
527
+
528
+ path = arguments.get("path")
529
+ mode = arguments.get("mode")
530
+ base = arguments.get("base")
531
+ include_context = arguments.get("include_context", False)
532
+ skills_override = arguments.get("skills")
533
+ include_skills = arguments.get("include_skills", True)
534
+ include_knowledge = arguments.get("include_knowledge", True)
535
+
536
+ # Determine if this is path-based or git-based review
537
+ git_context: GitContext | None = None
538
+ changed_files: list[str] = []
539
+
540
+ if mode:
541
+ # Git-based review
542
+ repo_path = path if path else os.getcwd()
543
+ root_result = get_repo_root(repo_path)
544
+ if root_result.is_err:
545
+ return [TextContent(type="text", text=f"Error: {root_result.error}")]
546
+ repo_path = root_result.value
547
+
548
+ # Get git context based on mode
549
+ if mode == "staged":
550
+ context_result = get_staged_changes(repo_path)
551
+ elif mode == "unstaged":
552
+ context_result = get_unstaged_changes(repo_path)
553
+ elif mode == "branch":
554
+ base_branch = base if base else "main"
555
+ context_result = get_branch_diff(repo_path, base_branch)
556
+ elif mode == "commits":
557
+ try:
558
+ count = int(base) if base else 1
559
+ except ValueError:
560
+ return [TextContent(type="text", text=f"Error: Invalid commit count '{base}'")]
561
+ context_result = get_recent_commits(repo_path, count)
562
+ else:
563
+ return [TextContent(type="text", text=f"Error: Unknown mode '{mode}'")]
564
+
565
+ if context_result.is_err:
566
+ return [TextContent(type="text", text=f"Error: {context_result.error}")]
567
+
568
+ git_context = context_result.value
569
+
570
+ if not git_context.changes:
571
+ if mode == "staged":
572
+ return [TextContent(type="text", text="No changes to review. Stage files with `git add` first.")]
573
+ elif mode == "unstaged":
574
+ return [TextContent(type="text", text="No unstaged changes to review.")]
575
+ else:
576
+ return [TextContent(type="text", text="No changes found.")]
577
+
578
+ changed_files = get_changed_files(git_context)
579
+ if not changed_files:
580
+ return [TextContent(type="text", text="No files to analyze (only deletions).")]
581
+
582
+ elif not path:
583
+ return [TextContent(type="text", text="Error: Either 'path' or 'mode' is required.")]
584
+
585
+ # Detect domains and run analysis
586
+ all_findings: list[ToolFinding] = []
587
+ tool_errors: list[str] = []
588
+ domains_detected: set[Domain] = set()
589
+ all_domain_tags: set[str] = set()
590
+
591
+ if git_context:
592
+ # Git mode: analyze each changed file
593
+ repo_path = get_repo_root(path if path else os.getcwd()).value
594
+ for file_path in changed_files:
595
+ full_path = f"{repo_path}/{file_path}"
596
+ domain, domain_tags = _detect_domain(file_path)
597
+ domains_detected.add(domain)
598
+ all_domain_tags.update(domain_tags)
599
+
600
+ findings, errors = _run_static_analysis(full_path, domain, domain_tags)
601
+ all_findings.extend(findings)
602
+ tool_errors.extend([f"{e} ({file_path})" for e in errors])
603
+
604
+ # Filter findings to changed lines
605
+ all_findings = _filter_findings_to_changes(all_findings, git_context, include_context)
606
+ else:
607
+ # Path mode: analyze the path directly
608
+ domain, domain_tags = _detect_domain(path)
609
+ domains_detected.add(domain)
610
+ all_domain_tags.update(domain_tags)
611
+
612
+ findings, errors = _run_static_analysis(path, domain, domain_tags)
613
+ all_findings.extend(findings)
614
+ tool_errors.extend(errors)
615
+
616
+ # Deduplicate findings
617
+ all_findings = _deduplicate_findings(all_findings)
618
+
619
+ # 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
624
+
625
+ # Load skills and knowledge
626
+ matched_skills: list[tuple[str, list[str]]] | None = None
627
+ skill_content: dict[str, str] | None = None
628
+ knowledge_files: set[str] | None = None
629
+ knowledge_content: dict[str, str] | None = None
630
+
631
+ if include_skills or include_knowledge:
632
+ primary_domain = next(iter(domains_detected)) if domains_detected else Domain.UNKNOWN
633
+ matched, s_content, k_files, k_content = _load_skills_and_knowledge(
634
+ primary_domain, list(all_domain_tags), skills_override
635
+ )
636
+ if include_skills:
637
+ matched_skills = matched
638
+ skill_content = s_content
639
+ if include_knowledge:
640
+ knowledge_files = k_files
641
+ knowledge_content = k_content
642
+
643
+ # Format output
644
+ output = _format_review_output(
645
+ path,
646
+ git_context,
647
+ list(all_domain_tags) if all_domain_tags else ["unknown"],
648
+ severity_counts,
649
+ all_findings,
650
+ tool_errors,
651
+ matched_skills,
652
+ skill_content,
653
+ knowledge_files,
654
+ knowledge_content,
655
+ )
656
+
657
+ return [TextContent(type="text", text=output)]
658
+
659
+
283
660
  def _handle_get_principles(arguments: dict[str, Any]) -> list[TextContent]:
284
661
  """Handle get_principles tool."""
285
662
  topic = arguments.get("topic")
@@ -609,6 +986,10 @@ def _format_change_review(
609
986
  findings: list[ToolFinding],
610
987
  severity_counts: dict[str, int],
611
988
  tool_errors: list[str] | None = None,
989
+ matched_skills: list[tuple[str, list[str]]] | None = None,
990
+ skill_content: dict[str, str] | None = None,
991
+ knowledge_files: set[str] | None = None,
992
+ knowledge_content: dict[str, str] | None = None,
612
993
  ) -> str:
613
994
  """Format change review output."""
614
995
  parts: list[str] = ["# Change Review\n"]
@@ -642,6 +1023,19 @@ def _format_change_review(
642
1023
  parts.append(f"- {msg}")
643
1024
  parts.append("")
644
1025
 
1026
+ # Applicable skills
1027
+ if matched_skills:
1028
+ parts.append("## Applicable Skills\n")
1029
+ for skill_name, triggers in matched_skills:
1030
+ parts.append(f"- **{skill_name}**: matched on {', '.join(triggers)}")
1031
+ parts.append("")
1032
+
1033
+ # Knowledge loaded
1034
+ if knowledge_files:
1035
+ parts.append("## Knowledge Loaded\n")
1036
+ parts.append(f"Files: {', '.join(sorted(knowledge_files))}")
1037
+ parts.append("")
1038
+
645
1039
  # Tool errors (if any)
646
1040
  if tool_errors:
647
1041
  parts.append("## Tool Errors\n")
@@ -657,6 +1051,25 @@ def _format_change_review(
657
1051
  else:
658
1052
  parts.append("## Findings in Changed Code\n")
659
1053
  parts.append("No issues found in changed code.")
1054
+ parts.append("")
1055
+
1056
+ # Review checklists from skills
1057
+ if skill_content:
1058
+ parts.append("---\n")
1059
+ parts.append("## Review Checklists\n")
1060
+ for skill_name, content in skill_content.items():
1061
+ parts.append(f"### {skill_name}\n")
1062
+ parts.append(content)
1063
+ parts.append("")
1064
+
1065
+ # Knowledge reference
1066
+ if knowledge_content:
1067
+ parts.append("---\n")
1068
+ parts.append("## Principles Reference\n")
1069
+ for filename, content in sorted(knowledge_content.items()):
1070
+ parts.append(f"### {filename}\n")
1071
+ parts.append(content)
1072
+ parts.append("")
660
1073
 
661
1074
  return "\n".join(parts)
662
1075
 
@@ -716,12 +1129,16 @@ def _handle_review_changes(arguments: dict[str, Any]) -> list[TextContent]:
716
1129
  # Run analysis on changed files
717
1130
  all_findings: list[ToolFinding] = []
718
1131
  tool_errors: list[str] = []
1132
+ domains_detected: set[Domain] = set()
1133
+ all_domain_tags: set[str] = set()
719
1134
 
720
1135
  for file_path in changed_files:
721
1136
  full_path = f"{repo_path}/{file_path}"
722
1137
 
723
1138
  # Detect domain for this file
724
1139
  domain, domain_tags = _detect_domain(file_path)
1140
+ domains_detected.add(domain)
1141
+ all_domain_tags.update(domain_tags)
725
1142
 
726
1143
  # Select tools based on domain
727
1144
  if domain == Domain.SMART_CONTRACT:
@@ -775,8 +1192,45 @@ def _handle_review_changes(arguments: dict[str, Any]) -> list[TextContent]:
775
1192
  sev = f.severity.value
776
1193
  severity_counts[sev] = severity_counts.get(sev, 0) + 1
777
1194
 
1195
+ # Match skills and load knowledge based on detected domains
1196
+ from crucible.knowledge.loader import load_knowledge_file
1197
+ from crucible.skills.loader import (
1198
+ get_knowledge_for_skills,
1199
+ load_skill,
1200
+ match_skills_for_domain,
1201
+ )
1202
+
1203
+ primary_domain = next(iter(domains_detected)) if domains_detected else Domain.UNKNOWN
1204
+ matched_skills = match_skills_for_domain(
1205
+ primary_domain, list(all_domain_tags), override=None
1206
+ )
1207
+
1208
+ skill_names = [name for name, _ in matched_skills]
1209
+ skill_content: dict[str, str] = {}
1210
+ for skill_name, _triggers in matched_skills:
1211
+ result = load_skill(skill_name)
1212
+ if result.is_ok:
1213
+ _, content = result.value
1214
+ skill_content[skill_name] = content
1215
+
1216
+ knowledge_files = get_knowledge_for_skills(skill_names)
1217
+ knowledge_content: dict[str, str] = {}
1218
+ for filename in knowledge_files:
1219
+ result = load_knowledge_file(filename)
1220
+ if result.is_ok:
1221
+ knowledge_content[filename] = result.value
1222
+
778
1223
  # Format output
779
- output = _format_change_review(context, filtered_findings, severity_counts, tool_errors)
1224
+ output = _format_change_review(
1225
+ context,
1226
+ filtered_findings,
1227
+ severity_counts,
1228
+ tool_errors,
1229
+ matched_skills,
1230
+ skill_content,
1231
+ knowledge_files,
1232
+ knowledge_content,
1233
+ )
780
1234
  return [TextContent(type="text", text=output)]
781
1235
 
782
1236
 
@@ -937,16 +1391,20 @@ def _handle_full_review(arguments: dict[str, Any]) -> list[TextContent]:
937
1391
  async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
938
1392
  """Handle tool calls."""
939
1393
  handlers = {
1394
+ # Unified review tool
1395
+ "review": _handle_review,
1396
+ # Deprecated tools (kept for backwards compatibility)
1397
+ "quick_review": _handle_quick_review,
1398
+ "full_review": _handle_full_review,
1399
+ "review_changes": _handle_review_changes,
1400
+ # Other tools
940
1401
  "get_principles": _handle_get_principles,
941
1402
  "load_knowledge": _handle_load_knowledge,
942
1403
  "delegate_semgrep": _handle_delegate_semgrep,
943
1404
  "delegate_ruff": _handle_delegate_ruff,
944
1405
  "delegate_slither": _handle_delegate_slither,
945
1406
  "delegate_bandit": _handle_delegate_bandit,
946
- "quick_review": _handle_quick_review,
947
1407
  "check_tools": _handle_check_tools,
948
- "review_changes": _handle_review_changes,
949
- "full_review": _handle_full_review,
950
1408
  }
951
1409
 
952
1410
  handler = handlers.get(name)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crucible-mcp
3
- Version: 0.2.0
3
+ Version: 0.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
@@ -73,20 +73,33 @@ Code → Detect Domain → Load Personas + Knowledge → Claude with YOUR patter
73
73
 
74
74
  | Tool | Purpose |
75
75
  |------|---------|
76
- | `quick_review(path)` | Run analysis, return findings + domains |
77
- | `get_principles(topic)` | Load engineering knowledge |
76
+ | `review(path)` | Full review: analysis + skills + knowledge |
77
+ | `review(mode='staged')` | Review git changes with skills + knowledge |
78
+ | `load_knowledge(files)` | Load specific knowledge files |
79
+ | `get_principles(topic)` | Load engineering knowledge by topic |
78
80
  | `delegate_*` | Direct tool access (semgrep, ruff, slither, bandit) |
79
81
  | `check_tools()` | Show installed analysis tools |
80
82
 
83
+ The unified `review` tool supports:
84
+ - **Path-based**: `review(path="src/")` - analyze files/directories
85
+ - **Git-aware**: `review(mode="staged")` - analyze changes (staged, unstaged, branch, commits)
86
+ - **Quick mode**: `review(path, include_skills=false)` - analysis only, no skills/knowledge
87
+
81
88
  ## CLI
82
89
 
83
90
  ```bash
91
+ crucible init # Initialize .crucible/ for your project
92
+ crucible review # Review staged changes
93
+ crucible review --mode branch # Review current branch vs main
94
+ crucible ci generate # Generate GitHub Actions workflow
95
+
84
96
  crucible skills list # List all skills
85
- crucible skills show <skill> # Show which version is active
86
97
  crucible skills init <skill> # Copy to .crucible/ for customization
87
98
 
88
99
  crucible knowledge list # List all knowledge files
89
100
  crucible knowledge init <file> # Copy for customization
101
+
102
+ crucible hooks install # Install pre-commit hook
90
103
  ```
91
104
 
92
105
  See [ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full flow.
@@ -135,6 +148,6 @@ See [KNOWLEDGE.md](docs/KNOWLEDGE.md) for topics covered.
135
148
 
136
149
  ```bash
137
150
  pip install -e ".[dev]"
138
- pytest # Run tests (494 tests)
151
+ pytest # Run tests (509 tests)
139
152
  ruff check src/ --fix # Lint
140
153
  ```
@@ -1,8 +1,8 @@
1
1
  crucible/__init__.py,sha256=ZLZQWKmjTHaeeDijcOl3xmaEgoI2W3a8FCFwcieZGv0,77
2
- crucible/cli.py,sha256=eCOHbV1jn9SdjtRVB8-PnHah0e-mkrxSOFjMYpMTUCY,51651
2
+ crucible/cli.py,sha256=eNLB4_GdO4rump_lKDjopXoKjbdr3e4hmrNleTrq_CE,54042
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=GnT_wT9QJE5gAQYmYzSIuaj5hG2hKfOrHHK104tPtqA,34493
5
+ crucible/server.py,sha256=jIkcjiQYf9DZRDUw_e9ajoI3PxvTybpwJ9m3S_-kgZE,50768
6
6
  crucible/domain/__init__.py,sha256=2fsoB5wH2Pl3vtGRt4voYOSZ04-zLoW8pNq6nvzVMgU,118
7
7
  crucible/domain/detection.py,sha256=TNeLB_VQgS1AsT5BKDf_tIpGa47THrFoRXwU4u54VB0,1797
8
8
  crucible/hooks/__init__.py,sha256=k5oEWhTJKEQi-QWBfTbp1p6HaKg55_wVCBVD5pZzdqw,271
@@ -15,8 +15,8 @@ crucible/synthesis/__init__.py,sha256=CYrkZG4bdAjp8XdOh1smfKscd3YU5lZlaDLGwLE9c-
15
15
  crucible/tools/__init__.py,sha256=gFRThTk1E-fHzpe8bB5rtBG6Z6G-ysPzjVEHfKGbEYU,400
16
16
  crucible/tools/delegation.py,sha256=_x1y76No3qkmGjjROVvMx1pSKKwU59aRu5R-r07lVFU,12871
17
17
  crucible/tools/git.py,sha256=EmxRUt0jSFLa_mm_2Czt5rHdiFC0YK9IpaPDfRwlXVo,10051
18
- crucible_mcp-0.2.0.dist-info/METADATA,sha256=Jcnnfw7AxZ-vtqNNOCR9j43MaRxzvXn6oW05nR1wccI,3890
19
- crucible_mcp-0.2.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
20
- crucible_mcp-0.2.0.dist-info/entry_points.txt,sha256=18BZaH1OlFSFYtKuHq0Z8yYX8Wmx7Ikfqay-P00ZX3Q,83
21
- crucible_mcp-0.2.0.dist-info/top_level.txt,sha256=4hzuFgqbFPOO-WiU_DYxTm8VYIxTXh7Wlp0gRcWR0Cs,9
22
- crucible_mcp-0.2.0.dist-info/RECORD,,
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,,