contrarian 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Anthony Micho
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: contrarian
3
+ Version: 0.2.0
4
+ Summary: MCP server adding an independent second LLM perspective (Judge B) with full project graph context
5
+ Author-email: Anthony Micho <anthony.micho76@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/anthonymicho/contrarian
8
+ Keywords: mcp,llm,code-review,claude,judge
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: mcp
20
+ Requires-Dist: openai>=1.0
21
+ Requires-Dist: python-dotenv
22
+ Requires-Dist: networkx>=3.4
23
+ Requires-Dist: graphifyy[leiden]
24
+ Requires-Dist: json-repair>=0.6
25
+ Provides-Extra: all
26
+ Requires-Dist: graphifyy[all]; extra == "all"
27
+ Dynamic: license-file
28
+
29
+ # Contrarian
30
+
31
+ MCP server that adds a second LLM perspective (Judge B) directly inside Claude Code.
32
+
33
+ Claude Code is Judge A — it already has full project context. Contrarian adds Judge B, an independent model from a different lab, via a single tool call. Same rubric, different priors, no context switch.
34
+
35
+ ---
36
+
37
+ ## How it works
38
+
39
+ 1. Claude Code invokes `contrarian_review()` — no args for auto-detect, or pass specific files.
40
+ 2. Contrarian resolves the input: git diff, last commit, or full audit (in that order).
41
+ 3. Judge B (external model) reviews against `JUDGE.md` in the project root.
42
+ 4. Findings are returned inline and appended to `REPORT.md` for the dialogue log.
43
+
44
+ Auto-detect order:
45
+ - **diff** — any staged/unstaged changes or untracked files
46
+ - **last-commit** — clean tree → reviews the last commit
47
+ - **audit** — forced with `audit=true`, or nothing else found
48
+
49
+ ---
50
+
51
+ ## Installation
52
+
53
+ Requires Python 3.10+. You'll need a free API key from [DeepSeek](https://platform.deepseek.com) (no credit card required).
54
+
55
+ ```bash
56
+ pip install contrarian
57
+ contrarian setup
58
+ ```
59
+
60
+ `contrarian setup` prompts for your API key, then writes the MCP server config to `~/.claude/.claude.json` automatically. Restart Claude Code after running it.
61
+
62
+ That's it.
63
+
64
+ ---
65
+
66
+ ### Manual config (alternative)
67
+
68
+ If you prefer to configure manually, add to `~/.claude/.claude.json`:
69
+
70
+ ```json
71
+ {
72
+ "mcpServers": {
73
+ "contrarian": {
74
+ "type": "stdio",
75
+ "command": "contrarian",
76
+ "args": [],
77
+ "env": {
78
+ "JUDGE_B_API_KEY": "sk-or-...",
79
+ "JUDGE_B_BASE_URL": "https://openrouter.ai/api/v1"
80
+ }
81
+ }
82
+ }
83
+ }
84
+ ```
85
+
86
+ Default provider is DeepSeek (`api.deepseek.com`, model `deepseek-chat`). Change `JUDGE_B_BASE_URL` and `JUDGE_B_MODEL` to use any OpenAI-compatible provider (Gemini, Groq, Ollama, OpenRouter, etc.).
87
+
88
+ Anthropic models are blocked at runtime — Judge B must come from a different lab than Judge A.
89
+
90
+ ---
91
+
92
+ ## Usage
93
+
94
+ Inside any Claude Code session, the tool is available as `contrarian_review`.
95
+
96
+ ```
97
+ contrarian_review() # auto-detect mode
98
+ contrarian_review({ path: "src/auth.py" }) # single file, diff
99
+ contrarian_review({ path: "src/auth.py", full: true }) # full file
100
+ contrarian_review({ paths: ["src/a.py", "src/b.py"] }) # multi-file, reviewed as a unit
101
+ contrarian_review({ audit: true }) # force full repo walk
102
+ contrarian_review({ model: "google/gemini-3.1-pro-preview" }) # one-off model override
103
+ ```
104
+
105
+ ---
106
+
107
+ ## Rubric
108
+
109
+ Place a `JUDGE.md` at the project root to customize what Judge B looks for. Without it, a built-in fallback rubric applies.
110
+
111
+ The built-in rubric covers four dimensions:
112
+
113
+ | Dimension | What to find |
114
+ |---|---|
115
+ | **Exactitude** | Logic errors, wrong assumptions, incorrect implementation |
116
+ | **Missing** | Unhandled cases, absent validation, unconsidered implications |
117
+ | **Premises** | Assumptions that could be wrong |
118
+ | **Alternatives** | Fundamentally different approaches |
119
+
120
+ Output is always JSON:
121
+
122
+ ```json
123
+ {
124
+ "exactitude": { "verdict": "ok|warn|fail", "findings": [] },
125
+ "missing": [],
126
+ "premises": [],
127
+ "alternatives": []
128
+ }
129
+ ```
130
+
131
+ ---
132
+
133
+ ## Report log
134
+
135
+ Findings are appended to `REPORT.md` at the project root under `[Judge B]` blocks. Claude Code annotates findings under `[Judge A]` blocks. Judge B reads previous exchanges before each review — closed findings (`addressed`, `won't fix`, `disagree`) are not re-raised.
136
+
137
+ ---
138
+
139
+ ## Phase roadmap
140
+
141
+ - **Phase 2 (current):** MCP server + project graph context. Auto-detect mode. Python rewrite.
142
+ - **Phase 3:** Operational hardening — token budget guard, graph latency, detached HEAD handling.
143
+ - **Phase 4:** Agent watchdog — autonomous background review on commit.
@@ -0,0 +1,115 @@
1
+ # Contrarian
2
+
3
+ MCP server that adds a second LLM perspective (Judge B) directly inside Claude Code.
4
+
5
+ Claude Code is Judge A — it already has full project context. Contrarian adds Judge B, an independent model from a different lab, via a single tool call. Same rubric, different priors, no context switch.
6
+
7
+ ---
8
+
9
+ ## How it works
10
+
11
+ 1. Claude Code invokes `contrarian_review()` — no args for auto-detect, or pass specific files.
12
+ 2. Contrarian resolves the input: git diff, last commit, or full audit (in that order).
13
+ 3. Judge B (external model) reviews against `JUDGE.md` in the project root.
14
+ 4. Findings are returned inline and appended to `REPORT.md` for the dialogue log.
15
+
16
+ Auto-detect order:
17
+ - **diff** — any staged/unstaged changes or untracked files
18
+ - **last-commit** — clean tree → reviews the last commit
19
+ - **audit** — forced with `audit=true`, or nothing else found
20
+
21
+ ---
22
+
23
+ ## Installation
24
+
25
+ Requires Python 3.10+. You'll need a free API key from [DeepSeek](https://platform.deepseek.com) (no credit card required).
26
+
27
+ ```bash
28
+ pip install contrarian
29
+ contrarian setup
30
+ ```
31
+
32
+ `contrarian setup` prompts for your API key, then writes the MCP server config to `~/.claude/.claude.json` automatically. Restart Claude Code after running it.
33
+
34
+ That's it.
35
+
36
+ ---
37
+
38
+ ### Manual config (alternative)
39
+
40
+ If you prefer to configure manually, add to `~/.claude/.claude.json`:
41
+
42
+ ```json
43
+ {
44
+ "mcpServers": {
45
+ "contrarian": {
46
+ "type": "stdio",
47
+ "command": "contrarian",
48
+ "args": [],
49
+ "env": {
50
+ "JUDGE_B_API_KEY": "sk-or-...",
51
+ "JUDGE_B_BASE_URL": "https://openrouter.ai/api/v1"
52
+ }
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ Default provider is DeepSeek (`api.deepseek.com`, model `deepseek-chat`). Change `JUDGE_B_BASE_URL` and `JUDGE_B_MODEL` to use any OpenAI-compatible provider (Gemini, Groq, Ollama, OpenRouter, etc.).
59
+
60
+ Anthropic models are blocked at runtime — Judge B must come from a different lab than Judge A.
61
+
62
+ ---
63
+
64
+ ## Usage
65
+
66
+ Inside any Claude Code session, the tool is available as `contrarian_review`.
67
+
68
+ ```
69
+ contrarian_review() # auto-detect mode
70
+ contrarian_review({ path: "src/auth.py" }) # single file, diff
71
+ contrarian_review({ path: "src/auth.py", full: true }) # full file
72
+ contrarian_review({ paths: ["src/a.py", "src/b.py"] }) # multi-file, reviewed as a unit
73
+ contrarian_review({ audit: true }) # force full repo walk
74
+ contrarian_review({ model: "google/gemini-3.1-pro-preview" }) # one-off model override
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Rubric
80
+
81
+ Place a `JUDGE.md` at the project root to customize what Judge B looks for. Without it, a built-in fallback rubric applies.
82
+
83
+ The built-in rubric covers four dimensions:
84
+
85
+ | Dimension | What to find |
86
+ |---|---|
87
+ | **Exactitude** | Logic errors, wrong assumptions, incorrect implementation |
88
+ | **Missing** | Unhandled cases, absent validation, unconsidered implications |
89
+ | **Premises** | Assumptions that could be wrong |
90
+ | **Alternatives** | Fundamentally different approaches |
91
+
92
+ Output is always JSON:
93
+
94
+ ```json
95
+ {
96
+ "exactitude": { "verdict": "ok|warn|fail", "findings": [] },
97
+ "missing": [],
98
+ "premises": [],
99
+ "alternatives": []
100
+ }
101
+ ```
102
+
103
+ ---
104
+
105
+ ## Report log
106
+
107
+ Findings are appended to `REPORT.md` at the project root under `[Judge B]` blocks. Claude Code annotates findings under `[Judge A]` blocks. Judge B reads previous exchanges before each review — closed findings (`addressed`, `won't fix`, `disagree`) are not re-raised.
108
+
109
+ ---
110
+
111
+ ## Phase roadmap
112
+
113
+ - **Phase 2 (current):** MCP server + project graph context. Auto-detect mode. Python rewrite.
114
+ - **Phase 3:** Operational hardening — token budget guard, graph latency, detached HEAD handling.
115
+ - **Phase 4:** Agent watchdog — autonomous background review on commit.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "contrarian"
7
+ version = "0.2.0"
8
+ description = "MCP server adding an independent second LLM perspective (Judge B) with full project graph context"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Anthony Micho", email = "anthony.micho76@gmail.com" }]
13
+ keywords = ["mcp", "llm", "code-review", "claude", "judge"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Software Development :: Quality Assurance",
22
+ ]
23
+ dependencies = [
24
+ "mcp",
25
+ "openai>=1.0",
26
+ "python-dotenv",
27
+ "networkx>=3.4",
28
+ "graphifyy[leiden]",
29
+ "json-repair>=0.6",
30
+ ]
31
+
32
+ [project.urls]
33
+ Repository = "https://github.com/anthonymicho/contrarian"
34
+
35
+ [project.optional-dependencies]
36
+ all = ["graphifyy[all]"]
37
+
38
+ [project.scripts]
39
+ contrarian = "contrarian.server:main"
40
+
41
+ [tool.setuptools.packages.find]
42
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,240 @@
1
+ """Build and query contrarian-out/graph.json for context about reviewed files.
2
+
3
+ Graph is built automatically on first call, cached until any project file changes.
4
+ Stale rebuilds run in a background thread — the existing graph is used immediately
5
+ and the fresh one is ready for the next call. Only the very first build blocks.
6
+ """
7
+ from __future__ import annotations
8
+ import json
9
+ import threading
10
+ from pathlib import Path
11
+
12
+ _rebuild_lock = threading.Lock()
13
+ _rebuild_thread: threading.Thread | None = None
14
+ _rebuild_failed = False # set by background thread on error; cleared on next successful build
15
+
16
+ _GRAPH_SUBDIR = "contrarian-out"
17
+ _GRAPH_FILE = "graph.json"
18
+ _HEAD_FILE = "HEAD.sha"
19
+
20
+
21
+ def _graph_path(project_root: str) -> Path:
22
+ return Path(project_root) / _GRAPH_SUBDIR / _GRAPH_FILE
23
+
24
+
25
+ def _head_path(project_root: str) -> Path:
26
+ return Path(project_root) / _GRAPH_SUBDIR / _HEAD_FILE
27
+
28
+
29
+ def _current_head(project_root: str) -> str | None:
30
+ import subprocess
31
+ result = subprocess.run(
32
+ ["git", "rev-parse", "HEAD"],
33
+ cwd=project_root, capture_output=True, text=True,
34
+ )
35
+ return result.stdout.strip() if result.returncode == 0 else None
36
+
37
+
38
+ def _saved_head(project_root: str) -> str | None:
39
+ hp = _head_path(project_root)
40
+ return hp.read_text(encoding="utf-8").strip() if hp.exists() else None
41
+
42
+
43
+ def _is_stale(project_root: str) -> bool:
44
+ gp = _graph_path(project_root)
45
+ if not gp.exists():
46
+ return True
47
+ import subprocess
48
+ # Check HEAD SHA first — catches branch switches and resets that leave a clean tree
49
+ current = _current_head(project_root)
50
+ if current and current != _saved_head(project_root):
51
+ return True
52
+ result = subprocess.run(
53
+ ["git", "status", "--porcelain"],
54
+ cwd=project_root, capture_output=True, text=True,
55
+ )
56
+ # Any uncommitted change means potentially stale
57
+ if result.returncode == 0:
58
+ return bool(result.stdout.strip())
59
+ # Not a git repo — check mtimes of files the graph already tracks.
60
+ # Covers nested paths that a shallow iterdir() would miss.
61
+ graph_mtime = gp.stat().st_mtime
62
+ try:
63
+ graph_data = json.loads(gp.read_text(encoding="utf-8"))
64
+ except Exception:
65
+ return True
66
+ for node in graph_data.get("nodes", []):
67
+ src = node.get("source_file", "")
68
+ if not src:
69
+ continue
70
+ try:
71
+ if (Path(project_root) / src).stat().st_mtime > graph_mtime:
72
+ return True
73
+ except OSError:
74
+ pass
75
+ return False
76
+
77
+
78
+ def _log(msg: str) -> None:
79
+ import sys
80
+ print(f"[contrarian] {msg}", file=sys.stderr)
81
+
82
+
83
+ def _build_graph(project_root: str) -> bool:
84
+ try:
85
+ from graphify.detect import detect
86
+ from graphify.extract import extract
87
+ from graphify.build import build
88
+ from graphify.cluster import cluster
89
+ from graphify.export import to_json
90
+ except ImportError as e:
91
+ _log(f"Graph build skipped — graphifyy not installed: {e}")
92
+ return False
93
+
94
+ root = Path(project_root)
95
+ out_dir = root / _GRAPH_SUBDIR
96
+ out_dir.mkdir(exist_ok=True)
97
+
98
+ gitignore = root / ".gitignore"
99
+ entry = f"{_GRAPH_SUBDIR}/"
100
+ if not gitignore.exists() or entry not in gitignore.read_text(encoding="utf-8"):
101
+ with gitignore.open("a", encoding="utf-8") as f:
102
+ f.write(f"\n{entry}\n")
103
+
104
+ manifest = detect(root)
105
+ files = manifest.get("files", [])
106
+ if not files:
107
+ _log("Graph build skipped — no source files detected by graphifyy")
108
+ return False
109
+
110
+ extractions_dict = extract(files, cache_root=out_dir)
111
+ raw = list(extractions_dict.values()) if isinstance(extractions_dict, dict) else extractions_dict
112
+ extractions = []
113
+ for e in raw:
114
+ try:
115
+ if e:
116
+ extractions.append(e)
117
+ except Exception:
118
+ pass
119
+ if not extractions:
120
+ _log("Graph build skipped — all extractions were empty")
121
+ return False
122
+
123
+ G = build(extractions, directed=True)
124
+ communities = cluster(G)
125
+ # Write to a temp file then rename atomically so concurrent reads never see a partial write
126
+ tmp = out_dir / (_GRAPH_FILE + ".tmp")
127
+ to_json(G, communities, str(tmp), force=True)
128
+ tmp.rename(out_dir / _GRAPH_FILE)
129
+ # Persist HEAD SHA so branch switches are detected next time
130
+ head = _current_head(project_root)
131
+ if head:
132
+ _head_path(project_root).write_text(head + "\n", encoding="utf-8")
133
+ return True
134
+
135
+
136
+ def _load_graph(project_root: str) -> dict | None:
137
+ gp = _graph_path(project_root)
138
+ if not gp.exists():
139
+ return None
140
+ try:
141
+ return json.loads(gp.read_text(encoding="utf-8"))
142
+ except Exception:
143
+ return None
144
+
145
+
146
+ def _nodes_for_files(graph: dict, paths: list[str]) -> list[str]:
147
+ path_set = set(paths)
148
+ basenames = {Path(p).name for p in paths}
149
+ result = []
150
+ for n in graph.get("nodes", []):
151
+ src = n.get("source_file", "")
152
+ if not src:
153
+ continue
154
+ src_path = Path(src)
155
+ # Prefer exact relative-path match; fall back to basename only when the
156
+ # graph node stores a bare filename (no directory component), to avoid
157
+ # false matches between files with identical names in different directories.
158
+ if src in path_set or (src_path.parent == Path(".") and src_path.name in basenames):
159
+ result.append(n["id"])
160
+ return result
161
+
162
+
163
+ def _one_hop_summary(graph: dict, node_ids: list[str]) -> str:
164
+ id_set = set(node_ids)
165
+ node_map = {n["id"]: n for n in graph.get("nodes", [])}
166
+
167
+ node_lines = [
168
+ f"- {node_map[nid].get('label', nid)} ({node_map[nid].get('source_file', '')})"
169
+ for nid in node_ids if nid in node_map
170
+ ]
171
+
172
+ rel_lines: list[str] = []
173
+ neighbour_ids: set[str] = set()
174
+ for edge in graph.get("links", graph.get("edges", [])):
175
+ src, tgt = edge.get("source", ""), edge.get("target", "")
176
+ if src in id_set or tgt in id_set:
177
+ other = tgt if src in id_set else src
178
+ neighbour_ids.add(other)
179
+ rel = edge.get("relation", "relates_to")
180
+ src_label = node_map.get(src, {}).get("label", src)
181
+ tgt_label = node_map.get(tgt, {}).get("label", tgt)
182
+ rel_lines.append(f" {src_label} --{rel}--> {tgt_label}")
183
+
184
+ parts: list[str] = []
185
+ if node_lines:
186
+ parts.append("Reviewed nodes:\n" + "\n".join(node_lines))
187
+ if rel_lines:
188
+ parts.append("Relationships:\n" + "\n".join(rel_lines[:40]))
189
+ if neighbour_ids:
190
+ labels = [node_map.get(n, {}).get("label", n) for n in list(neighbour_ids)[:20]]
191
+ parts.append("Neighbours: " + ", ".join(labels))
192
+
193
+ return "\n\n".join(parts)
194
+
195
+
196
+ def _rebuild_background(project_root: str) -> None:
197
+ global _rebuild_thread
198
+
199
+ if _rebuild_thread and _rebuild_thread.is_alive():
200
+ return
201
+
202
+ def _worker():
203
+ global _rebuild_failed
204
+ try:
205
+ with _rebuild_lock:
206
+ _build_graph(project_root)
207
+ _rebuild_failed = False
208
+ except Exception as exc:
209
+ _rebuild_failed = True
210
+ _log(f"Background graph rebuild failed: {exc}")
211
+
212
+ _rebuild_thread = threading.Thread(target=_worker, daemon=True, name="contrarian-graph-rebuild")
213
+ _rebuild_thread.start()
214
+
215
+
216
+ def get_graph_context(project_root: str, paths: list[str]) -> str:
217
+ global _rebuild_failed
218
+ graph_exists = _graph_path(project_root).exists()
219
+ stale = _is_stale(project_root)
220
+
221
+ if stale or _rebuild_failed:
222
+ if graph_exists and not _rebuild_failed:
223
+ # Serve existing graph; rebuild in background for next call
224
+ _rebuild_background(project_root)
225
+ else:
226
+ # First build, or last background rebuild failed — block and retry.
227
+ # Propagates on failure so the caller sees the error instead of silent stale data.
228
+ _rebuild_failed = False
229
+ with _rebuild_lock:
230
+ _build_graph(project_root)
231
+
232
+ graph = _load_graph(project_root)
233
+ if not graph:
234
+ return ""
235
+
236
+ node_ids = _nodes_for_files(graph, paths)
237
+ if not node_ids:
238
+ return ""
239
+
240
+ return _one_hop_summary(graph, node_ids)