dissenter 1.0.2__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.
dissent/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.1"
dissent/cli.py ADDED
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Optional, List
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.rule import Rule
11
+ from rich.tree import Tree
12
+
13
+ from .config import DissentConfig, ModelConfig, RoundConfig, load_config
14
+ from .detect import KNOWN_PROVIDERS, detect_api_keys, detect_clis, detect_ollama_models, infer_auth
15
+ from .runner import run_all_rounds
16
+ from .synthesis import synthesize
17
+
18
+ app = typer.Typer(
19
+ help="dissenter — multi-LLM debate engine for architectural decisions.",
20
+ add_completion=False,
21
+ )
22
+
23
+ out = Console()
24
+ err = Console(stderr=True)
25
+
26
+ _DEFAULT_DEBATE_ROLES = ["skeptic", "contrarian", "pragmatist", "devil's advocate", "analyst"]
27
+
28
+
29
+ # ── Config builders for inline flags ─────────────────────────────────────────
30
+
31
+ def _config_from_flags(
32
+ models: list[str],
33
+ chairman: str | None,
34
+ output_dir: Path | None,
35
+ ) -> DissentConfig:
36
+ clis = detect_clis()
37
+ debate_models: list[ModelConfig] = []
38
+ for i, spec in enumerate(models):
39
+ # Format: model_id or model_id@role
40
+ if "@" in spec:
41
+ model_id, role = spec.rsplit("@", 1)
42
+ else:
43
+ model_id = spec
44
+ role = _DEFAULT_DEBATE_ROLES[i % len(_DEFAULT_DEBATE_ROLES)]
45
+ extra = {"api_base": "http://localhost:11434"} if model_id.startswith("ollama/") else {}
46
+ debate_models.append(
47
+ ModelConfig(id=model_id, role=role, auth=infer_auth(model_id, clis), extra=extra)
48
+ )
49
+
50
+ chair_id = chairman or models[0].split("@")[0]
51
+ chair_extra = {"api_base": "http://localhost:11434"} if chair_id.startswith("ollama/") else {}
52
+ chairman_model = ModelConfig(
53
+ id=chair_id, role="chairman",
54
+ auth=infer_auth(chair_id, clis), extra=chair_extra,
55
+ )
56
+ return DissentConfig(
57
+ output_dir=output_dir or Path("decisions"),
58
+ rounds=[
59
+ RoundConfig(name="debate", models=debate_models),
60
+ RoundConfig(name="final", models=[chairman_model]),
61
+ ],
62
+ )
63
+
64
+
65
+ def _config_from_quick(output_dir: Path | None) -> DissentConfig:
66
+ ollama_models = detect_ollama_models()
67
+ if not ollama_models:
68
+ raise RuntimeError(
69
+ "--quick requires local Ollama models. "
70
+ "Install Ollama and run 'ollama pull mistral' first."
71
+ )
72
+ debate_models = [
73
+ ModelConfig(
74
+ id=f"ollama/{m}",
75
+ role=_DEFAULT_DEBATE_ROLES[i % len(_DEFAULT_DEBATE_ROLES)],
76
+ extra={"api_base": "http://localhost:11434"},
77
+ )
78
+ for i, m in enumerate(ollama_models)
79
+ ]
80
+ chairman = ModelConfig(
81
+ id=f"ollama/{ollama_models[0]}",
82
+ role="chairman",
83
+ extra={"api_base": "http://localhost:11434"},
84
+ )
85
+ return DissentConfig(
86
+ output_dir=output_dir or Path("decisions"),
87
+ rounds=[
88
+ RoundConfig(name="debate", models=debate_models),
89
+ RoundConfig(name="final", models=[chairman]),
90
+ ],
91
+ )
92
+
93
+
94
+ # ── Commands ──────────────────────────────────────────────────────────────────
95
+
96
+ @app.command()
97
+ def ask(
98
+ question: str = typer.Argument(..., help="The question to debate"),
99
+ config: Optional[Path] = typer.Option(None, "--config", "-c", help="Config file path"),
100
+ output_dir: Optional[Path] = typer.Option(None, "--output", "-o", help="Output directory"),
101
+ model: Optional[List[str]] = typer.Option(
102
+ None, "--model", "-m",
103
+ help="model_id[@role] for the debate round — repeat for multiple models",
104
+ ),
105
+ chairman: Optional[str] = typer.Option(
106
+ None, "--chairman", help="Model ID for the final (synthesis) round"
107
+ ),
108
+ quick: bool = typer.Option(
109
+ False, "--quick", help="Auto-detect installed Ollama models and run immediately"
110
+ ),
111
+ ) -> None:
112
+ """Run the full debate pipeline and synthesize a decision.
113
+
114
+ Config priority: --quick > --model/--chairman > --config / dissenter.toml
115
+ """
116
+ try:
117
+ if quick:
118
+ cfg = _config_from_quick(output_dir)
119
+ elif model:
120
+ cfg = _config_from_flags(list(model), chairman, output_dir)
121
+ else:
122
+ cfg = load_config(config)
123
+ if output_dir:
124
+ cfg.output_dir = output_dir
125
+ except (FileNotFoundError, RuntimeError) as e:
126
+ err.print(f"[red]Error:[/red] {e}")
127
+ raise typer.Exit(1)
128
+
129
+ total_models = sum(len(r.active_models) for r in cfg.rounds)
130
+ err.print()
131
+ err.print(Rule("[bold]dissenter[/bold]"))
132
+ err.print(f" [dim]Question:[/dim] {question}")
133
+ err.print(f" [dim]Rounds :[/dim] {len(cfg.rounds)}")
134
+ err.print(f" [dim]Models :[/dim] {total_models} across all rounds")
135
+ err.print()
136
+
137
+ try:
138
+ all_rounds, final_text, synthesis_results = asyncio.run(_main(question, cfg))
139
+ except RuntimeError as e:
140
+ err.print()
141
+ err.print(f"[red]Error:[/red] {e}")
142
+ err.print()
143
+ raise typer.Exit(1)
144
+
145
+ # Save outputs — everything lives under decisions/<timestamp>/
146
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
147
+ run_dir = cfg.output_dir / ts
148
+ run_dir.mkdir(parents=True, exist_ok=True)
149
+
150
+ for rr in all_rounds:
151
+ round_dir = run_dir / f"round_{rr.round_index + 1}_{rr.round_name}"
152
+ round_dir.mkdir()
153
+ for r in rr.results:
154
+ safe = r.model_id.replace("/", "_").replace(":", "_")
155
+ role_safe = r.role.replace(" ", "_").replace("'", "")
156
+ fname = f"{safe}__{role_safe}"
157
+ (round_dir / f"{fname}.md").write_text(r.content or "")
158
+ if r.error:
159
+ (round_dir / f"{fname}.err").write_text(r.error)
160
+
161
+ final_round_dir = run_dir / f"round_{len(all_rounds)}_{cfg.rounds[-1].name or 'final'}"
162
+ final_round_dir.mkdir(exist_ok=True)
163
+ for r in synthesis_results:
164
+ safe = r.model_id.replace("/", "_").replace(":", "_")
165
+ role_safe = r.role.replace(" ", "_").replace("'", "")
166
+ (final_round_dir / f"{safe}__{role_safe}.md").write_text(r.content or "")
167
+
168
+ output_file = run_dir / "decision.md"
169
+ output_file.write_text(final_text)
170
+
171
+ abs_file = output_file.absolute()
172
+ abs_dir = run_dir.absolute()
173
+
174
+ err.print()
175
+ err.print(Rule("[bold green]Done[/bold green]"))
176
+ err.print(f" [green]Decision :[/green] [link=file://{abs_file}]{abs_file}[/link]")
177
+ err.print(f" [dim]Run dir : [link=file://{abs_dir}]{abs_dir}[/link][/dim]")
178
+ err.print()
179
+
180
+
181
+ @app.command()
182
+ def init(
183
+ output: Path = typer.Option(Path("dissenter.toml"), "--output", "-o", help="Config file to create"),
184
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite without prompting"),
185
+ ) -> None:
186
+ """Interactively create a dissenter.toml config file."""
187
+ from .wizard import run_wizard
188
+ run_wizard(output, force, err)
189
+
190
+
191
+ @app.command()
192
+ def models() -> None:
193
+ """Show detected local models, CLI tools, and API key status."""
194
+ from rich.table import Table
195
+
196
+ ollama = detect_ollama_models()
197
+ clis = detect_clis()
198
+ api_keys = detect_api_keys()
199
+
200
+ out.print()
201
+
202
+ # Ollama
203
+ out.print("[bold]Ollama (local)[/bold]")
204
+ if ollama:
205
+ for m in ollama:
206
+ out.print(f" [green]✓[/green] {m}")
207
+ else:
208
+ out.print(" [dim]no models — is ollama running? try: ollama pull mistral[/dim]")
209
+
210
+ # CLI tools
211
+ out.print()
212
+ out.print("[bold]CLI tools[/bold]")
213
+ for cli, path in clis.items():
214
+ if path:
215
+ out.print(f" [green]✓[/green] {cli:<10} [dim]{path}[/dim]")
216
+ else:
217
+ out.print(f" [dim]✗ {cli} not found[/dim]")
218
+
219
+ # API providers
220
+ out.print()
221
+ out.print("[bold]API providers[/bold]")
222
+ for provider, env_var in KNOWN_PROVIDERS.items():
223
+ has_key = api_keys[provider]
224
+ tick = "[green]✓[/green]" if has_key else "[dim]✗[/dim]"
225
+ note = "[green]key set[/green]" if has_key else f"[dim]export {env_var}[/dim]"
226
+ out.print(f" {tick} {provider:<18} {note}")
227
+
228
+ out.print()
229
+
230
+
231
+ @app.command()
232
+ def show(
233
+ config: Optional[Path] = typer.Option(None, "--config", "-c", help="Config file path"),
234
+ ) -> None:
235
+ """Show the current configuration — rounds, models, roles."""
236
+ try:
237
+ cfg = load_config(config)
238
+ except FileNotFoundError as e:
239
+ err.print(f"[red]Error:[/red] {e}")
240
+ raise typer.Exit(1)
241
+
242
+ tree = Tree(f"[bold]dissenter[/bold] [dim]{cfg.output_dir}[/dim]")
243
+ for i, round_cfg in enumerate(cfg.rounds):
244
+ label = f"Round {i+1}: [cyan]{round_cfg.name or '(unnamed)'}[/cyan]"
245
+ if i == len(cfg.rounds) - 1:
246
+ label += " [yellow][final][/yellow]"
247
+ r_node = tree.add(label)
248
+ for m in round_cfg.models:
249
+ status = "[green]✓[/green]" if m.enabled else "[dim]—[/dim]"
250
+ r_node.add(
251
+ f"{status} [bold]{m.id}[/bold] [dim]role:[/dim] {m.role} "
252
+ f"[dim]auth:[/dim] {m.auth} [dim]timeout:[/dim] {m.timeout}s"
253
+ )
254
+ if round_cfg.combine_model:
255
+ r_node.add(f"[dim]combine via:[/dim] {round_cfg.combine_model}")
256
+
257
+ out.print(tree)
258
+
259
+ if cfg.role_distribution:
260
+ out.print()
261
+ out.print("[dim]Role distribution:[/dim]")
262
+ for role, weight in cfg.role_distribution.items():
263
+ out.print(f" {role}: {weight:.0%}")
264
+
265
+
266
+ async def _main(question: str, cfg: DissentConfig):
267
+ err.print(Rule("[dim]beginning debate[/dim]", style="dim"))
268
+ all_rounds = await run_all_rounds(cfg, question)
269
+
270
+ err.print()
271
+ err.print(Rule("[dim]synthesizing[/dim]", style="dim"))
272
+
273
+ final_text, synthesis_results = await synthesize(question, all_rounds, cfg)
274
+ return all_rounds, final_text, synthesis_results
dissent/config.py ADDED
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ import tomllib
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from platformdirs import user_config_dir
9
+ from pydantic import BaseModel, Field, model_validator
10
+
11
+
12
+ class ModelConfig(BaseModel):
13
+ id: str
14
+ role: str = "analyst"
15
+ enabled: bool = True
16
+ timeout: int = 180
17
+ # API auth (default): litellm reads key from env var, or use api_key to override
18
+ api_key: str | None = None
19
+ # CLI auth: shell out to the provider's CLI tool instead of the API
20
+ # auth = "cli" uses the CLI's stored session (e.g. a logged-in `claude` install)
21
+ auth: str = "api" # "api" | "cli"
22
+ cli_command: str | None = None # e.g. "claude", "gemini"; inferred from provider if None
23
+ extra: dict[str, Any] = Field(default_factory=dict)
24
+
25
+
26
+ class RoundConfig(BaseModel):
27
+ name: str = ""
28
+ models: list[ModelConfig] = Field(default_factory=list)
29
+ combine_model: str | None = None
30
+ combine_timeout: int = 60
31
+
32
+ @property
33
+ def active_models(self) -> list[ModelConfig]:
34
+ return [m for m in self.models if m.enabled]
35
+
36
+
37
+ class DissentConfig(BaseModel):
38
+ output_dir: Path = Path("decisions")
39
+ default_model: str | None = None
40
+ rounds: list[RoundConfig] = Field(default_factory=list)
41
+ role_distribution: dict[str, float] = Field(default_factory=dict)
42
+
43
+ @model_validator(mode="after")
44
+ def validate_rounds(self) -> "DissentConfig":
45
+ if not self.rounds:
46
+ raise ValueError("At least one [[rounds]] block must be configured")
47
+ last = self.rounds[-1]
48
+ n = len(last.active_models)
49
+ if n not in (1, 2):
50
+ raise ValueError(
51
+ f"Final round must have exactly 1 or 2 enabled models, got {n}. "
52
+ "Use 1 model with role 'chairman', or 2 models with a combine_model."
53
+ )
54
+ if n == 2 and last.combine_model is None:
55
+ raise ValueError(
56
+ "Final round with 2 models requires combine_model to be set "
57
+ "(the model that merges their dual recommendations side-by-side)."
58
+ )
59
+ return self
60
+
61
+ @property
62
+ def is_dual_final(self) -> bool:
63
+ return len(self.rounds[-1].active_models) == 2
64
+
65
+
66
+ def load_config(path: Path | None = None) -> DissentConfig:
67
+ candidates: list[Path] = []
68
+ if path:
69
+ candidates.append(path)
70
+ candidates.append(Path("dissenter.toml"))
71
+ candidates.append(Path(user_config_dir("dissenter")) / "config.toml")
72
+
73
+ for candidate in candidates:
74
+ if candidate.exists():
75
+ data = tomllib.loads(candidate.read_text())
76
+ return DissentConfig.model_validate(data)
77
+
78
+ raise FileNotFoundError(
79
+ "No dissenter.toml found. Create one with `dissenter init` or pass --config <path>."
80
+ )
81
+
82
+
83
+ def assign_random_roles(
84
+ model_ids: list[str],
85
+ distribution: dict[str, float],
86
+ ) -> list[tuple[str, str]]:
87
+ """Randomly assign roles from a weighted distribution to a list of model IDs.
88
+
89
+ Returns list of (model_id, role) pairs.
90
+ """
91
+ if not distribution:
92
+ raise ValueError("role_distribution must be non-empty for random assignment")
93
+ roles = list(distribution.keys())
94
+ weights = list(distribution.values())
95
+ assigned = random.choices(roles, weights=weights, k=len(model_ids))
96
+ return list(zip(model_ids, assigned))
dissent/detect.py ADDED
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+
7
+ # Known providers and the env var that holds their API key
8
+ KNOWN_PROVIDERS: dict[str, str] = {
9
+ "anthropic": "ANTHROPIC_API_KEY",
10
+ "openai": "OPENAI_API_KEY",
11
+ "gemini": "GEMINI_API_KEY",
12
+ "mistral": "MISTRAL_API_KEY",
13
+ "groq": "GROQ_API_KEY",
14
+ "cohere": "CO_API_KEY",
15
+ "together_ai": "TOGETHERAI_API_KEY",
16
+ "openrouter": "OPENROUTER_API_KEY",
17
+ }
18
+
19
+
20
+ def detect_ollama_models() -> list[str]:
21
+ """Return list of locally installed Ollama model names."""
22
+ try:
23
+ result = subprocess.run(
24
+ ["ollama", "list"],
25
+ capture_output=True,
26
+ text=True,
27
+ timeout=5,
28
+ )
29
+ if result.returncode != 0:
30
+ return []
31
+ lines = result.stdout.strip().splitlines()
32
+ models: list[str] = []
33
+ for line in lines[1:]: # skip NAME header
34
+ parts = line.split()
35
+ if parts:
36
+ models.append(parts[0])
37
+ return models
38
+ except Exception:
39
+ return []
40
+
41
+
42
+ def detect_clis() -> dict[str, str | None]:
43
+ """Return mapping of CLI name -> absolute path (None if not found)."""
44
+ return {
45
+ "claude": shutil.which("claude"),
46
+ "gemini": shutil.which("gemini"),
47
+ }
48
+
49
+
50
+ def detect_api_keys() -> dict[str, bool]:
51
+ """Return mapping of provider name -> whether its env var is set."""
52
+ return {
53
+ provider: bool(os.environ.get(env_var))
54
+ for provider, env_var in KNOWN_PROVIDERS.items()
55
+ }
56
+
57
+
58
+ def infer_auth(model_id: str, clis: dict[str, str | None]) -> str:
59
+ """Return 'cli' if a CLI tool is available for this model's provider, else 'api'."""
60
+ provider = model_id.split("/")[0]
61
+ if provider == "anthropic" and clis.get("claude"):
62
+ return "cli"
63
+ if provider in ("gemini", "google") and clis.get("gemini"):
64
+ return "cli"
65
+ return "api"
@@ -0,0 +1,3 @@
1
+ name = "analyst"
2
+ description = "Rigorous balanced analysis with concrete numbers"
3
+ prompt = "Your role is balanced analyst. Be rigorous and cite specific trade-offs with concrete numbers and real-world examples where possible."
@@ -0,0 +1,3 @@
1
+ name = "chairman"
2
+ description = "Decisive synthesis after all debate"
3
+ prompt = "Your role is chairman. You have read all the debate that preceded you. Synthesize into a decisive, actionable recommendation. Do not hedge. Make the call."
@@ -0,0 +1,3 @@
1
+ name = "conservative"
2
+ description = "Pragmatic executor — safest proven path"
3
+ prompt = "Your role is the pragmatic executor. Given all preceding debate, recommend the safest, most proven path forward. Favor what can be shipped, maintained, and reversed if wrong. Be terse — your output will be placed side-by-side with the liberal view."
@@ -0,0 +1,3 @@
1
+ name = "contrarian"
2
+ description = "Surface the minority expert view and missed nuance"
3
+ prompt = "Your role is contrarian. What would the minority expert view look like? What important nuance does the mainstream consensus miss?"
@@ -0,0 +1,3 @@
1
+ name = "devil's advocate"
2
+ description = "Argue against the obvious or popular choice"
3
+ prompt = "Your role is devil's advocate. Argue against the obvious or popular choice. Find the strongest case for what most experts would dismiss."
@@ -0,0 +1,3 @@
1
+ name = "liberal"
2
+ description = "Ambitious visionary — boldest high-upside path"
3
+ prompt = "Your role is the ambitious visionary. Given all preceding debate, recommend the boldest, highest-upside path forward. Favor what could be transformative if it works. Be terse — your output will be placed side-by-side with the conservative view."
@@ -0,0 +1,3 @@
1
+ name = "pragmatist"
2
+ description = "Focus on what actually works in production at scale"
3
+ prompt = "Your role is pragmatist. Focus on what actually works in production at scale. Ignore theoretical elegance — what does real-world deployment look like?"
@@ -0,0 +1,3 @@
1
+ name = "researcher"
2
+ description = "Find the most current information using web access"
3
+ prompt = "Your role is researcher. Use your web access to find the most current information. Focus on recent developments, version changes, and community momentum since 2024."
@@ -0,0 +1,3 @@
1
+ name = "second opinion"
2
+ description = "Fresh-eyes independent review"
3
+ prompt = "Your role is independent reviewer. Approach this with fresh eyes, question assumptions, and highlight anything the other analyses might miss."
@@ -0,0 +1,3 @@
1
+ name = "skeptic"
2
+ description = "Find hidden failure modes and long-term risks"
3
+ prompt = "Your role is skeptic. What are the hidden failure modes, long-term maintenance costs, and risks nobody is talking about? Assume the optimistic case is wrong."
dissent/roles.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+
6
+ _ROLES_DIR = Path(__file__).parent / "roles"
7
+
8
+ _FALLBACK_PROMPT = (
9
+ "Your role is balanced analyst. Be rigorous and cite specific trade-offs "
10
+ "with concrete numbers and real-world examples where possible."
11
+ )
12
+
13
+
14
+ def load_roles() -> dict[str, str]:
15
+ """Return mapping of role name → prompt text, loaded from roles/*.toml."""
16
+ roles: dict[str, str] = {}
17
+ for path in _ROLES_DIR.glob("*.toml"):
18
+ data = tomllib.loads(path.read_text())
19
+ name = data.get("name", path.stem)
20
+ prompt = data.get("prompt", _FALLBACK_PROMPT)
21
+ roles[name] = prompt
22
+ return roles
23
+
24
+
25
+ def get_prompt(role: str, roles: dict[str, str] | None = None) -> str:
26
+ """Return the prompt for a role, falling back to analyst if unknown."""
27
+ if roles is None:
28
+ roles = load_roles()
29
+ return roles.get(role, _FALLBACK_PROMPT)
dissent/runner.py ADDED
@@ -0,0 +1,318 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from dataclasses import dataclass, field
6
+ from typing import Optional
7
+
8
+ import litellm
9
+ from rich.console import Console
10
+ from rich.live import Live
11
+ from rich.rule import Rule
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+
15
+ from .config import DissentConfig, ModelConfig, RoundConfig
16
+ from .roles import get_prompt, load_roles
17
+
18
+ litellm.suppress_debug_info = True
19
+
20
+ console = Console(stderr=True)
21
+
22
+ _SPECIALIST_PROMPT = """\
23
+ You are a senior software architect. {role_instruction}
24
+
25
+ {prior_context}Analyze this question:
26
+ {question}
27
+
28
+ Respond in this exact markdown structure:
29
+
30
+ ## Recommendation
31
+ [Your clear, opinionated recommendation in 1-2 sentences]
32
+
33
+ ## Pros
34
+ - [bullet]
35
+
36
+ ## Cons / Risks
37
+ - [bullet]
38
+
39
+ ## Critical Considerations
40
+ - [bullet]
41
+
42
+ ## Recent Developments (2024-2026)
43
+ [Relevant ecosystem changes. Note if you have no web access.]
44
+
45
+ Be direct, technical, and opinionated. Engineers will act on this.
46
+ """
47
+
48
+ _PRIOR_CONTEXT_TEMPLATE = """\
49
+ [Prior debate — Round {index}: "{name}"]
50
+
51
+ {responses}
52
+
53
+ [End prior debate]
54
+
55
+ """
56
+
57
+
58
+ @dataclass
59
+ class ModelResult:
60
+ model_id: str
61
+ role: str
62
+ round_name: str
63
+ content: str = ""
64
+ elapsed: float = 0.0
65
+ error: Optional[str] = None
66
+
67
+ @property
68
+ def success(self) -> bool:
69
+ return bool(self.content) and self.error is None
70
+
71
+ @property
72
+ def word_count(self) -> int:
73
+ return len(self.content.split())
74
+
75
+ @property
76
+ def short_id(self) -> str:
77
+ return self.model_id.split("/")[-1]
78
+
79
+
80
+ @dataclass
81
+ class RoundResult:
82
+ round_name: str
83
+ round_index: int
84
+ results: list[ModelResult] = field(default_factory=list)
85
+
86
+ @property
87
+ def successful(self) -> list[ModelResult]:
88
+ return [r for r in self.results if r.success]
89
+
90
+
91
+ def _build_prior_context(prior_rounds: list[RoundResult]) -> str:
92
+ if not prior_rounds:
93
+ return ""
94
+ parts = []
95
+ for rr in prior_rounds:
96
+ responses = "\n\n".join(
97
+ f"**{r.short_id}** (role: {r.role}):\n{r.content}"
98
+ for r in rr.successful
99
+ )
100
+ parts.append(
101
+ _PRIOR_CONTEXT_TEMPLATE.format(
102
+ index=rr.round_index + 1,
103
+ name=rr.round_name,
104
+ responses=responses,
105
+ )
106
+ )
107
+ return "".join(parts)
108
+
109
+
110
+ # Known provider → CLI command mappings
111
+ _PROVIDER_CLI: dict[str, str] = {
112
+ "anthropic": "claude",
113
+ "gemini": "gemini",
114
+ "google": "gemini",
115
+ }
116
+
117
+
118
+ def _infer_cli(model_id: str) -> str | None:
119
+ provider = model_id.split("/")[0]
120
+ return _PROVIDER_CLI.get(provider)
121
+
122
+
123
+ async def _query_model_cli(cfg: ModelConfig, prompt: str) -> str:
124
+ """Query a model via its CLI tool, using the CLI's stored session auth."""
125
+ cli = cfg.cli_command or _infer_cli(cfg.id)
126
+ if not cli:
127
+ raise ValueError(
128
+ f"No CLI command known for provider '{cfg.id.split('/')[0]}'. "
129
+ "Set cli_command in config (e.g. cli_command = \"claude\")."
130
+ )
131
+
132
+ # All CLIs: pass prompt via stdin with non-interactive/print flag
133
+ # claude: `claude --print` gemini: `gemini` (add more as needed)
134
+ cli_args: list[str] = [cli]
135
+ if cli == "claude":
136
+ cli_args += ["--print"]
137
+
138
+ proc = await asyncio.create_subprocess_exec(
139
+ *cli_args,
140
+ stdin=asyncio.subprocess.PIPE,
141
+ stdout=asyncio.subprocess.PIPE,
142
+ stderr=asyncio.subprocess.PIPE,
143
+ )
144
+ stdout, stderr = await proc.communicate(input=prompt.encode())
145
+ if proc.returncode != 0:
146
+ err_text = stderr.decode().strip()
147
+ raise RuntimeError(err_text or f"'{cli}' exited with code {proc.returncode}")
148
+ return stdout.decode().strip()
149
+
150
+
151
+ def _classify_error(exc: Exception) -> str:
152
+ msg = str(exc)
153
+ t = type(exc).__name__
154
+ if "AuthenticationError" in t or "authentication" in msg.lower() or "api key" in msg.lower() or "Missing API Key" in msg:
155
+ provider = msg.split(":")[0].strip() if ":" in msg else "provider"
156
+ return f"missing/invalid API key ({provider}) — set the env var or add api_key to config"
157
+ if "NotFoundError" in t or "model not found" in msg.lower() or (
158
+ "pull" in msg.lower() and "ollama" in msg.lower()
159
+ ):
160
+ return "model not installed — run 'ollama pull <model>' first"
161
+ if "APIConnectionError" in t or "Connection refused" in msg or "OllamaError" in t:
162
+ if "not found" in msg.lower() or "try pulling" in msg.lower():
163
+ return "model not installed — run 'ollama pull <model>' first"
164
+ if "ollama" in msg.lower() or "11434" in msg:
165
+ return "cannot reach Ollama — is 'ollama serve' running?"
166
+ return f"connection failed — is the server running? ({msg[:80]})"
167
+ if "RateLimitError" in t:
168
+ return "rate limited — wait and retry, or switch models"
169
+ if "ContextWindowExceededError" in t or "context length" in msg.lower():
170
+ return "context window exceeded — debate history too long for this model"
171
+ return msg[:120]
172
+
173
+
174
+ async def _query_model(
175
+ cfg: ModelConfig,
176
+ round_name: str,
177
+ question: str,
178
+ prior_context: str,
179
+ role_prompts: dict[str, str],
180
+ ) -> ModelResult:
181
+ role_instruction = get_prompt(cfg.role, role_prompts)
182
+ prompt = _SPECIALIST_PROMPT.format(
183
+ role_instruction=role_instruction,
184
+ prior_context=prior_context,
185
+ question=question,
186
+ )
187
+ start = time.monotonic()
188
+ result = ModelResult(model_id=cfg.id, role=cfg.role, round_name=round_name)
189
+
190
+ try:
191
+ if cfg.auth == "cli":
192
+ result.content = await asyncio.wait_for(
193
+ _query_model_cli(cfg, prompt),
194
+ timeout=cfg.timeout,
195
+ )
196
+ else:
197
+ kwargs: dict = {
198
+ "model": cfg.id,
199
+ "messages": [{"role": "user", "content": prompt}],
200
+ **cfg.extra,
201
+ }
202
+ if cfg.api_key:
203
+ kwargs["api_key"] = cfg.api_key
204
+ response = await asyncio.wait_for(
205
+ litellm.acompletion(**kwargs),
206
+ timeout=cfg.timeout,
207
+ )
208
+ result.content = response.choices[0].message.content or ""
209
+ result.elapsed = time.monotonic() - start
210
+ except asyncio.TimeoutError:
211
+ result.error = f"timed out after {cfg.timeout}s"
212
+ result.elapsed = float(cfg.timeout)
213
+ except Exception as exc:
214
+ result.error = _classify_error(exc)
215
+ result.elapsed = time.monotonic() - start
216
+
217
+ return result
218
+
219
+
220
+ def _status_table(
221
+ round_name: str,
222
+ round_index: int,
223
+ results: dict[str, ModelResult],
224
+ done: set[str],
225
+ start_times: dict[str, float],
226
+ ) -> Table:
227
+ now = time.monotonic()
228
+ table = Table(
229
+ title=f"Round {round_index + 1}: {round_name}",
230
+ show_header=True,
231
+ header_style="bold",
232
+ box=None,
233
+ padding=(0, 1),
234
+ )
235
+ table.add_column("Model", min_width=26)
236
+ table.add_column("Role", min_width=18)
237
+ table.add_column("Time", justify="right", min_width=6)
238
+ table.add_column("Status", min_width=22)
239
+
240
+ for key, result in results.items():
241
+ elapsed = result.elapsed if key in done else now - start_times[key]
242
+ elapsed_str = f"{elapsed:.0f}s"
243
+
244
+ if key not in done:
245
+ status = Text("⠸ running", style="yellow")
246
+ elif result.error:
247
+ status = Text(f"✗ {result.error[:35]}", style="red")
248
+ else:
249
+ status = Text(f"✓ ~{result.word_count} words", style="green")
250
+
251
+ table.add_row(result.short_id, result.role, elapsed_str, status)
252
+
253
+ return table
254
+
255
+
256
+ async def run_round(
257
+ round_cfg: RoundConfig,
258
+ round_index: int,
259
+ question: str,
260
+ prior_rounds: list[RoundResult],
261
+ role_prompts: dict[str, str],
262
+ ) -> RoundResult:
263
+ active = round_cfg.active_models
264
+ prior_context = _build_prior_context(prior_rounds)
265
+ round_name = round_cfg.name or f"round_{round_index + 1}"
266
+
267
+ # Use (model_id, role, index) as unique key to support same model with different roles
268
+ keys = [f"{m.id}::{m.role}::{i}" for i, m in enumerate(active)]
269
+ results: dict[str, ModelResult] = {
270
+ k: ModelResult(model_id=m.id, role=m.role, round_name=round_name)
271
+ for k, m in zip(keys, active)
272
+ }
273
+ done: set[str] = set()
274
+ start_times: dict[str, float] = {k: time.monotonic() for k in keys}
275
+
276
+ async def run_and_track(key: str, cfg: ModelConfig) -> None:
277
+ result = await _query_model(cfg, round_name, question, prior_context, role_prompts)
278
+ results[key] = result
279
+ done.add(key)
280
+
281
+ tasks = [asyncio.create_task(run_and_track(k, m)) for k, m in zip(keys, active)]
282
+
283
+ with Live(console=console, refresh_per_second=4, transient=False) as live:
284
+ while not all(t.done() for t in tasks):
285
+ live.update(_status_table(round_name, round_index, results, done, start_times))
286
+ await asyncio.sleep(0.25)
287
+ live.update(_status_table(round_name, round_index, results, done, start_times))
288
+
289
+ round_result = RoundResult(round_name=round_name, round_index=round_index)
290
+ round_result.results = list(results.values())
291
+ return round_result
292
+
293
+
294
+ async def run_all_rounds(
295
+ cfg: DissentConfig,
296
+ question: str,
297
+ ) -> list[RoundResult]:
298
+ role_prompts = load_roles()
299
+ all_results: list[RoundResult] = []
300
+
301
+ for i, round_cfg in enumerate(cfg.rounds):
302
+ active = round_cfg.active_models
303
+ if not active:
304
+ console.print(f"[yellow]Round {i+1} '{round_cfg.name}' has no enabled models, skipping.[/yellow]")
305
+ continue
306
+
307
+ console.print()
308
+ console.print(Rule(f"[bold]Round {i+1} of {len(cfg.rounds)}: {round_cfg.name or ''}[/bold] ({len(active)} models)", style="dim"))
309
+
310
+ rr = await run_round(round_cfg, i, question, all_results, role_prompts)
311
+ all_results.append(rr)
312
+
313
+ if not rr.successful:
314
+ raise RuntimeError(
315
+ f"All models failed in round {i+1} '{round_cfg.name}'. Cannot continue."
316
+ )
317
+
318
+ return all_results
dissent/synthesis.py ADDED
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from datetime import date
5
+
6
+ import litellm
7
+
8
+ from .config import DissentConfig, ModelConfig, RoundConfig
9
+ from .roles import get_prompt, load_roles
10
+ from .runner import ModelResult, RoundResult, _query_model_cli
11
+
12
+ _SYNTHESIS_PROMPT = """\
13
+ You are a principal architect writing a formal Architectural Decision Record (ADR).
14
+
15
+ Multiple AI models debated across {n_rounds} round(s). Here are all their outputs:
16
+
17
+ {all_round_outputs}
18
+
19
+ ---
20
+
21
+ Synthesize into an ADR using this EXACT structure (do not deviate):
22
+
23
+ # ADR: [derive a concise title]
24
+
25
+ **Date:** {date}
26
+ **Status:** Proposed
27
+ **Debate rounds:** {n_rounds} | **Models consulted:** {n_models_total}
28
+
29
+ ## Context
30
+ [2-3 sentences on the problem and why this decision matters]
31
+
32
+ ## Consensus
33
+ [What most/all models agreed on — high-confidence signals]
34
+
35
+ ## Disagreements
36
+ [Where models diverged. For each: what was the disagreement, why it matters, what context would resolve it]
37
+
38
+ ## Options Considered
39
+
40
+ | Option | Pros | Cons | Risk Level |
41
+ |--------|------|------|------------|
42
+
43
+ ## Decision
44
+ **[The recommendation in one clear sentence.]**
45
+
46
+ [2-3 paragraphs of rationale: draw on consensus, resolve disagreements, be explicit about what assumptions this decision rests on]
47
+
48
+ ## Consequences
49
+
50
+ ### Positive
51
+ - [bullet]
52
+
53
+ ### Risks
54
+ - [bullet]
55
+
56
+ ### Mitigations
57
+ - [bullet]
58
+
59
+ ## Open Questions
60
+ [Points that require your specific context — team size, existing stack, constraints — that no model could know]
61
+
62
+ ---
63
+ *Synthesized by dissenter from {n_models_total} model responses across {n_rounds} round(s)*
64
+
65
+ Be ruthlessly clear, technically precise, and opinionated. No filler.
66
+ """
67
+
68
+ _DUAL_ARBITER_PROMPT = """\
69
+ You are a senior architect. Your role: {role_instruction}
70
+
71
+ All preceding debate is below. Read it carefully, then write your recommendation.
72
+
73
+ {all_round_outputs}
74
+
75
+ ---
76
+
77
+ Question: {question}
78
+
79
+ Write a terse, opinionated recommendation. Your output will be shown side-by-side with another architect's view.
80
+
81
+ ## Your Recommendation
82
+ [1 clear sentence]
83
+
84
+ ## Why
85
+ [2-3 bullet points max]
86
+
87
+ ## Risks
88
+ [1-2 bullet points max]
89
+
90
+ ## What would change your answer
91
+ [1 sentence]
92
+ """
93
+
94
+ _COMBINE_PROMPT = """\
95
+ Two senior architects reviewed the same debate and provided their recommendations.
96
+ Format them side by side for the human to compare and decide.
97
+
98
+ CONSERVATIVE view (pragmatic executor — safest proven path):
99
+ {conservative_output}
100
+
101
+ LIBERAL view (ambitious visionary — boldest high-upside path):
102
+ {liberal_output}
103
+
104
+ Format as this EXACT structure:
105
+
106
+ # Dual Recommendation: {title}
107
+
108
+ **Date:** {date}
109
+ **Question:** {question}
110
+
111
+ ---
112
+
113
+ ## Conservative View — Ship It
114
+ {conservative_output}
115
+
116
+ ---
117
+
118
+ ## Liberal View — Go Bold
119
+ {liberal_output}
120
+
121
+ ---
122
+
123
+ ## Key Divergence
124
+ [2-3 sentences: what is the core difference between these two views, and what specific context (team size, risk tolerance, timeline, existing stack) would make one more appropriate than the other]
125
+ """
126
+
127
+
128
+ def _format_all_rounds(all_rounds: list[RoundResult]) -> str:
129
+ parts = []
130
+ for rr in all_rounds:
131
+ header = f"### Round {rr.round_index + 1}: {rr.round_name}"
132
+ parts.append(header)
133
+ for r in rr.successful:
134
+ parts.append(f"\n**{r.short_id}** (role: *{r.role}*)\n\n{r.content}")
135
+ parts.append("\n---")
136
+ return "\n".join(parts)
137
+
138
+
139
+ async def _call_model(cfg: ModelConfig, prompt: str) -> str:
140
+ if cfg.auth == "cli":
141
+ return await asyncio.wait_for(
142
+ _query_model_cli(cfg, prompt),
143
+ timeout=float(cfg.timeout),
144
+ )
145
+ kwargs: dict = {
146
+ "model": cfg.id,
147
+ "messages": [{"role": "user", "content": prompt}],
148
+ **(cfg.extra or {}),
149
+ }
150
+ if cfg.api_key:
151
+ kwargs["api_key"] = cfg.api_key
152
+ response = await asyncio.wait_for(
153
+ litellm.acompletion(**kwargs),
154
+ timeout=float(cfg.timeout),
155
+ )
156
+ return response.choices[0].message.content or ""
157
+
158
+
159
+ async def synthesize(
160
+ question: str,
161
+ all_rounds: list[RoundResult],
162
+ cfg: DissentConfig,
163
+ ) -> tuple[str, list[ModelResult]]:
164
+ """
165
+ Returns (final_text, final_model_results).
166
+ final_model_results contains the synthesis model outputs for saving to debug dir.
167
+ """
168
+ role_prompts = load_roles()
169
+ final_round = cfg.rounds[-1]
170
+ active = final_round.active_models
171
+ all_round_outputs = _format_all_rounds(all_rounds[:-1] if len(all_rounds) == len(cfg.rounds) else all_rounds)
172
+
173
+ # Count total models consulted across all debate rounds (not final)
174
+ debate_rounds = all_rounds[:-1] if len(all_rounds) >= len(cfg.rounds) else all_rounds
175
+ n_models_total = sum(len(rr.successful) for rr in all_rounds)
176
+
177
+ if len(active) == 1:
178
+ # Single chairman/arbiter
179
+ arbiter = active[0]
180
+ prompt = _SYNTHESIS_PROMPT.format(
181
+ question=question,
182
+ all_round_outputs=_format_all_rounds(all_rounds),
183
+ date=date.today().isoformat(),
184
+ n_rounds=len(all_rounds),
185
+ n_models_total=n_models_total,
186
+ )
187
+ content = await _call_model(arbiter, prompt)
188
+ result = ModelResult(
189
+ model_id=arbiter.id, role=arbiter.role, round_name=final_round.name or "final",
190
+ content=content, elapsed=0.0,
191
+ )
192
+ return content, [result]
193
+
194
+ else:
195
+ # Dual arbiter: conservative + liberal
196
+ debate_context = _format_all_rounds(all_rounds)
197
+
198
+ async def call_arbiter(m) -> ModelResult:
199
+ role_instruction = get_prompt(m.role, role_prompts)
200
+ prompt = _DUAL_ARBITER_PROMPT.format(
201
+ role_instruction=role_instruction,
202
+ all_round_outputs=debate_context,
203
+ question=question,
204
+ )
205
+ content = await _call_model(m, prompt)
206
+ return ModelResult(
207
+ model_id=m.id, role=m.role, round_name=final_round.name or "final",
208
+ content=content, elapsed=0.0,
209
+ )
210
+
211
+ results = await asyncio.gather(*[call_arbiter(m) for m in active])
212
+
213
+ # Identify conservative vs liberal
214
+ conservative_result = next((r for r in results if r.role == "conservative"), results[0])
215
+ liberal_result = next((r for r in results if r.role == "liberal"), results[1])
216
+
217
+ # Combine side-by-side
218
+ title = question[:60] + ("..." if len(question) > 60 else "")
219
+ combine_prompt = _COMBINE_PROMPT.format(
220
+ conservative_output=conservative_result.content,
221
+ liberal_output=liberal_result.content,
222
+ title=title,
223
+ date=date.today().isoformat(),
224
+ question=question,
225
+ )
226
+ combine_cfg = ModelConfig(
227
+ id=final_round.combine_model,
228
+ role="combiner",
229
+ timeout=final_round.combine_timeout,
230
+ )
231
+ combined = await _call_model(combine_cfg, combine_prompt)
232
+ return combined, list(results)
dissent/wizard.py ADDED
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+
9
+ from .detect import detect_clis, detect_ollama_models, infer_auth
10
+
11
+ _DEBATE_ROLES = [
12
+ "skeptic",
13
+ "contrarian",
14
+ "pragmatist",
15
+ "devil's advocate",
16
+ "analyst",
17
+ "researcher",
18
+ ]
19
+
20
+
21
+ def _collect_model(
22
+ label: str,
23
+ clis: dict[str, str | None],
24
+ default_role: str,
25
+ console: Console,
26
+ ) -> dict:
27
+ console.print(f"\n [bold]{label}[/bold]")
28
+ model_id = typer.prompt(" ID (e.g. ollama/mistral, anthropic/claude-sonnet-4-6)")
29
+ role = typer.prompt(" Role", default=default_role)
30
+ auth = typer.prompt(" Auth (api/cli)", default=infer_auth(model_id, clis))
31
+ timeout = int(typer.prompt(" Timeout (s)", default="180"))
32
+ m: dict = {"id": model_id, "role": role, "auth": auth, "timeout": timeout}
33
+ if model_id.startswith("ollama/"):
34
+ api_base = typer.prompt(" Ollama API base", default="http://localhost:11434")
35
+ m["extra"] = {"api_base": api_base}
36
+ return m
37
+
38
+
39
+ def _render_toml(rounds_data: list[dict], output_dir: str) -> str:
40
+ lines = [
41
+ "# dissenter — multi-LLM debate engine",
42
+ f'output_dir = "{output_dir}"',
43
+ "",
44
+ ]
45
+ for i, rd in enumerate(rounds_data):
46
+ is_final = i == len(rounds_data) - 1
47
+ label = f"Final round: {rd['name']}" if is_final else f"Round {i + 1}: {rd['name']}"
48
+ fill = "─" * max(4, 52 - len(label))
49
+ lines.append(f"# ── {label} {fill}")
50
+ lines.append("[[rounds]]")
51
+ lines.append(f'name = "{rd["name"]}"')
52
+ if rd.get("combine_model"):
53
+ lines.append(f'combine_model = "{rd["combine_model"]}"')
54
+ lines.append(f'combine_timeout = {rd.get("combine_timeout", 60)}')
55
+ lines.append("")
56
+ for m in rd["models"]:
57
+ lines.append("[[rounds.models]]")
58
+ lines.append(f'id = "{m["id"]}"')
59
+ lines.append(f'role = "{m["role"]}"')
60
+ if m.get("auth") == "cli":
61
+ lines.append(f'auth = "cli"')
62
+ lines.append(f'timeout = {m["timeout"]}')
63
+ if m.get("extra"):
64
+ kv = ", ".join(f'{k} = "{v}"' for k, v in m["extra"].items())
65
+ lines.append(f'extra = {{ {kv} }}')
66
+ lines.append("")
67
+ return "\n".join(lines).rstrip() + "\n"
68
+
69
+
70
+ def run_wizard(output_path: Path, force: bool, console: Console) -> None:
71
+ """Interactive config wizard. Writes a dissent.toml to output_path."""
72
+ clis = detect_clis()
73
+ ollama_models = detect_ollama_models()
74
+
75
+ env_lines: list[str] = []
76
+ for cli, path in clis.items():
77
+ if path:
78
+ env_lines.append(f" [green]✓[/green] {cli} CLI [dim]{path}[/dim]")
79
+ else:
80
+ env_lines.append(f" [dim]✗ {cli} CLI not found[/dim]")
81
+ if ollama_models:
82
+ env_lines.append(
83
+ f" [green]✓[/green] Ollama [dim]{', '.join(ollama_models)}[/dim]"
84
+ )
85
+ else:
86
+ env_lines.append(" [dim]✗ Ollama not found or no models installed[/dim]")
87
+
88
+ console.print(Panel("\n".join(env_lines), title="[bold]dissenter init[/bold]", expand=False))
89
+ console.print()
90
+
91
+ if output_path.exists() and not force:
92
+ if not typer.confirm(f"{output_path} already exists. Overwrite?", default=False):
93
+ raise typer.Exit(0)
94
+
95
+ output_dir = typer.prompt("Output directory", default="decisions")
96
+ n_debate = int(typer.prompt("Debate rounds (not counting final)", default="1"))
97
+
98
+ rounds_data: list[dict] = []
99
+
100
+ for ri in range(n_debate):
101
+ console.print(f"\n[bold]── Round {ri + 1} ──[/bold]")
102
+ name = typer.prompt(" Name", default="debate" if ri == 0 else f"round_{ri + 1}")
103
+ n_models = int(typer.prompt(" Models", default="2"))
104
+ models = [
105
+ _collect_model(
106
+ f"Model {mi + 1}", clis, _DEBATE_ROLES[mi % len(_DEBATE_ROLES)], console
107
+ )
108
+ for mi in range(n_models)
109
+ ]
110
+ rounds_data.append({"name": name, "models": models})
111
+
112
+ console.print("\n[bold]── Final Round ──[/bold]")
113
+ final_type = typer.prompt(" Type (chairman/dual)", default="chairman")
114
+ final_name = typer.prompt(" Name", default="final")
115
+
116
+ if final_type == "dual":
117
+ con = _collect_model("Conservative model", clis, "conservative", console)
118
+ con["role"] = "conservative"
119
+ lib = _collect_model("Liberal model", clis, "liberal", console)
120
+ lib["role"] = "liberal"
121
+ combine = typer.prompt(" Combine model ID")
122
+ combine_timeout = int(typer.prompt(" Combine timeout (s)", default="60"))
123
+ rounds_data.append({
124
+ "name": final_name,
125
+ "models": [con, lib],
126
+ "combine_model": combine,
127
+ "combine_timeout": combine_timeout,
128
+ })
129
+ else:
130
+ chair = _collect_model("Chairman model", clis, "chairman", console)
131
+ chair["role"] = "chairman"
132
+ rounds_data.append({"name": final_name, "models": [chair]})
133
+
134
+ toml_content = _render_toml(rounds_data, output_dir)
135
+ console.print("\n[bold]── Preview ──[/bold]\n")
136
+ console.print(toml_content)
137
+
138
+ if typer.confirm(f"Save to {output_path}?", default=True):
139
+ output_path.write_text(toml_content)
140
+ console.print(f"\n[green]✓[/green] Saved [bold]{output_path}[/bold]")
141
+ console.print(f" Run [bold]dissenter ask \"your question\"[/bold] to get started.")
142
+ else:
143
+ console.print("[dim]Cancelled.[/dim]")
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: dissenter
3
+ Version: 1.0.2
4
+ Summary: Multi-LLM debate engine for architectural decisions
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: litellm>=1.62.0
7
+ Requires-Dist: platformdirs>=4.3.0
8
+ Requires-Dist: pydantic>=2.9.0
9
+ Requires-Dist: rich>=13.9.0
10
+ Requires-Dist: typer>=0.12.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
13
+ Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -0,0 +1,22 @@
1
+ dissent/__init__.py,sha256=d4QHYmS_30j0hPN8NmNPnQ_Z0TphDRbu4MtQj9cT9e8,22
2
+ dissent/cli.py,sha256=MNK6ubB3Ra_pKkIbMDxUUbgRb4fTlt1-gvN4K_c1-BI,9869
3
+ dissent/config.py,sha256=5s4bGKAAB2xdQUGmLgFNYCxhtd1176YyJifNLT-aFQQ,3341
4
+ dissent/detect.py,sha256=M13r9v5GddSB-AgXtDtbHPXMAXw3jjY6XRuLD2_0x74,1904
5
+ dissent/roles.py,sha256=HOkeB8Rpw5-ykq1E7W2V9Kk0XK6Hdi9IrDVq58suXpM,921
6
+ dissent/runner.py,sha256=HAJ2KoygHCseZoAdbLRNhow5fxzPM3az5Y52nXV_y04,9969
7
+ dissent/synthesis.py,sha256=3e2SixvIuiPUq89-3BiX3BKBtBJQacsW3ZhcTM2060k,6858
8
+ dissent/wizard.py,sha256=6GYl8GgDPGIbY2AnWXcIM1BaD_b8a0YBZ6j-DIbkWFg,5538
9
+ dissent/roles/analyst.toml,sha256=5GovW6THlT_BG016C_MmCQLIX0DVrdpcgWtnumwOC9A,227
10
+ dissent/roles/chairman.toml,sha256=6L-ZAo3fvbHODBEem3qmRzelhYL4-dQ5n4J7Rd1JWH0,236
11
+ dissent/roles/conservative.toml,sha256=RMyy-AQuwFW_skck3c_5OF8yrOupymb2xL4vnmCkluw,342
12
+ dissent/roles/contrarian.toml,sha256=OiDYiD4bgwjB9SeupVp_rGXuSrrIeN3WVKxPgDLlHvw,228
13
+ dissent/roles/devils_advocate.toml,sha256=vaJnhVtNXBdAlyX8IlF_IO4TNQvqjQsvdr5zPd0VliU,234
14
+ dissent/roles/liberal.toml,sha256=rP5PsRLPgEF2_5W8sJc_lCwo09x51GRQe52lVlTbrCg,340
15
+ dissent/roles/pragmatist.toml,sha256=4hTm4mHVMo9Re6399qiz-WVBLr5D9iGyHD3LDSLDNRU,252
16
+ dissent/roles/researcher.toml,sha256=H7GQhwJXyhtwLeQKGfmLQ_-pzQ4C7V2onhtXCkEWAFU,263
17
+ dissent/roles/second_opinion.toml,sha256=BZu-Dw6Z1FJpzFjPR_Cqn0qAuRfATQeq7Y3wR0dvfuA,223
18
+ dissent/roles/skeptic.toml,sha256=p9lUoeH8FbmtYlEqVHBUVzfmLMzmcpDSAMW7lDXCzVU,248
19
+ dissenter-1.0.2.dist-info/METADATA,sha256=KN8V_NSxEo85Boan0yyWG-dI2VIp2LVQ4Ij_Y1wC34Y,407
20
+ dissenter-1.0.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
21
+ dissenter-1.0.2.dist-info/entry_points.txt,sha256=UnjlVR-sazqqILOfWbfpO8XeJN9UgikeRNOUb8HgZiI,46
22
+ dissenter-1.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dissenter = dissent.cli:app