paircode 0.7.1__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.
paircode/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """paircode — adversarial journey framework for LLM peer review.
2
+
3
+ See README.md and diary/001-step-a-architecture.md for the full design.
4
+ """
5
+ __version__ = "0.7.0"
paircode/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from paircode.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
paircode/cli.py ADDED
@@ -0,0 +1,419 @@
1
+ """paircode CLI entry point.
2
+
3
+ Subcommands:
4
+ install — register /paircode in all detected LLM CLIs (Claude, Codex, Gemini)
5
+ uninstall — remove paircode entries from those CLIs (idempotent)
6
+ handshake — list detected CLIs, no install
7
+ status — summarize current .paircode/ state (step B)
8
+ init — bootstrap .paircode/ in cwd (step B)
9
+ focus — open/list focuses (step B)
10
+ stage — run one peer-review round at a stage (step B)
11
+ drive — high-level workflow driver: topic → research → plan → execute (step B)
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import re
16
+
17
+ import click
18
+ from rich.console import Console
19
+ from rich.table import Table
20
+
21
+ from paircode import __version__
22
+ from paircode.detect import detect_all
23
+ from paircode.drive import drive_full, drive_research, run_stage
24
+ from paircode.handshake import propose_roster, proposed_as_yaml_dicts
25
+ from paircode.installer import install_all, uninstall_all
26
+ from paircode.journey import note_focus_opened
27
+ from paircode.seal import discover_latest_versions, seal_stage
28
+ from paircode.state import (
29
+ find_paircode,
30
+ init_paircode,
31
+ open_focus,
32
+ read_peers,
33
+ write_peers,
34
+ )
35
+
36
+
37
+ console = Console()
38
+
39
+
40
+ @click.group(invoke_without_command=True)
41
+ @click.version_option(__version__, prog_name="paircode")
42
+ @click.pass_context
43
+ def main(ctx: click.Context) -> None:
44
+ """paircode — adversarial journey framework for LLM peer review.
45
+
46
+ Run `paircode install` to register /paircode in your LLM CLIs.
47
+ Run `paircode drive "<topic>"` to kick off a research → plan → execute loop.
48
+ Run `paircode --help` for the full command list.
49
+ """
50
+ if ctx.invoked_subcommand is None:
51
+ console.print(f"[bold]paircode[/bold] v{__version__}")
52
+ console.print("Run [cyan]paircode --help[/cyan] to see available commands.")
53
+ console.print(
54
+ "First-time setup: [cyan]paircode install[/cyan] "
55
+ "(registers /paircode in Claude, Codex, Gemini)."
56
+ )
57
+
58
+
59
+ @main.command()
60
+ def install() -> None:
61
+ """Install /paircode slash command into every detected LLM CLI (global scope)."""
62
+ console.print("[bold]Installing paircode into detected LLM CLIs...[/bold]\n")
63
+ results = install_all()
64
+ table = Table(show_header=True, header_style="bold")
65
+ table.add_column("CLI")
66
+ table.add_column("Action")
67
+ table.add_column("Details")
68
+ for r in results:
69
+ color = {"installed": "green", "skipped": "yellow", "failed": "red"}.get(r.action, "white")
70
+ table.add_row(r.cli_name, f"[{color}]{r.action}[/{color}]", r.message)
71
+ console.print(table)
72
+ installed = sum(1 for r in results if r.action == "installed")
73
+ skipped = sum(1 for r in results if r.action == "skipped")
74
+ failed = sum(1 for r in results if r.action == "failed")
75
+ console.print(
76
+ f"\n[bold]Summary:[/bold] {installed} installed, {skipped} skipped, {failed} failed."
77
+ )
78
+ if installed > 0:
79
+ console.print(
80
+ "\nTry it: open Claude Code in any project and run [cyan]/paircode[/cyan]."
81
+ )
82
+
83
+
84
+ @main.command()
85
+ def uninstall() -> None:
86
+ """Remove /paircode from every LLM CLI config dir (idempotent)."""
87
+ console.print("[bold]Uninstalling paircode from LLM CLIs...[/bold]\n")
88
+ results = uninstall_all()
89
+ for r in results:
90
+ console.print(f" [dim]{r.cli_name}:[/dim] {r.message}")
91
+
92
+
93
+ @main.command()
94
+ @click.option("--write", is_flag=True, help="Write proposed roster to .paircode/peers.yaml")
95
+ def handshake(write: bool) -> None:
96
+ """List detected LLM CLIs and propose a peer roster.
97
+
98
+ Without --write, just shows what was detected and what roster would be proposed.
99
+ With --write, saves the proposed roster to .paircode/peers.yaml (requires init first).
100
+ """
101
+ detected = detect_all()
102
+ dtable = Table(title="Detected CLIs", show_header=True, header_style="bold")
103
+ dtable.add_column("CLI")
104
+ dtable.add_column("Installed")
105
+ dtable.add_column("Binary / Install hint")
106
+ for name, info in detected.items():
107
+ status = "[green]yes[/green]" if info.installed else "[red]no[/red]"
108
+ right = str(info.binary_path) if info.binary_path else f"[dim]{info.install_hint}[/dim]"
109
+ dtable.add_row(name, status, right)
110
+ console.print(dtable)
111
+
112
+ proposed = propose_roster()
113
+ if not proposed:
114
+ console.print(
115
+ "\n[yellow]No peers detected (only alpha is available). Install "
116
+ "codex/gemini/ollama to add peer voices.[/yellow]"
117
+ )
118
+ return
119
+
120
+ ptable = Table(title="Proposed peer roster", show_header=True, header_style="bold")
121
+ ptable.add_column("id")
122
+ ptable.add_column("cli")
123
+ ptable.add_column("mode")
124
+ ptable.add_column("priority")
125
+ ptable.add_column("notes")
126
+ for p in proposed:
127
+ ptable.add_row(p.id, p.cli, p.mode, p.priority, p.notes)
128
+ console.print(ptable)
129
+
130
+ if write:
131
+ state = find_paircode()
132
+ if state is None:
133
+ console.print(
134
+ "[red]No .paircode/ found.[/red] Run [cyan]paircode init[/cyan] first, then rerun."
135
+ )
136
+ return
137
+ write_peers(state, proposed_as_yaml_dicts(proposed))
138
+ console.print(
139
+ f"\n[green]✓[/green] Wrote roster to [cyan]{state.peers_path}[/cyan]"
140
+ )
141
+ else:
142
+ console.print(
143
+ "\n[dim]Run `paircode handshake --write` to save this roster to "
144
+ ".paircode/peers.yaml[/dim]"
145
+ )
146
+
147
+
148
+ @main.command()
149
+ def status() -> None:
150
+ """Summarize the current .paircode/ state (walks up from cwd)."""
151
+ state = find_paircode()
152
+ if state is None:
153
+ console.print(
154
+ "[yellow]No .paircode/ found in cwd or any parent. "
155
+ "Run [cyan]paircode init[/cyan] to bootstrap.[/yellow]"
156
+ )
157
+ return
158
+ console.print(f"[bold].paircode/[/bold] at [cyan]{state.root}[/cyan]")
159
+ console.print(f" project root: {state.project_root}")
160
+ console.print(f" focuses: {state.focus_count}")
161
+ if state.active_focus:
162
+ console.print(f" active focus: [green]{state.active_focus.name}[/green]")
163
+ peers = read_peers(state)
164
+ console.print(f" peers: {len(peers)} configured")
165
+ if peers:
166
+ ptable = Table(show_header=True, header_style="bold")
167
+ ptable.add_column("id")
168
+ ptable.add_column("cli")
169
+ ptable.add_column("mode")
170
+ ptable.add_column("priority")
171
+ for p in peers:
172
+ ptable.add_row(
173
+ str(p.get("id", "?")),
174
+ str(p.get("cli", "?")),
175
+ str(p.get("mode", "?")),
176
+ str(p.get("priority", "?")),
177
+ )
178
+ console.print(ptable)
179
+ if state.focus_dirs:
180
+ ftable = Table(show_header=True, header_style="bold")
181
+ ftable.add_column("#")
182
+ ftable.add_column("focus")
183
+ ftable.add_column("research")
184
+ ftable.add_column("plan")
185
+ ftable.add_column("execute")
186
+ for i, d in enumerate(state.focus_dirs, 1):
187
+ per_stage_cols = []
188
+ for stage in ("research", "plan", "execute"):
189
+ stage_dir = d / stage
190
+ if not stage_dir.exists():
191
+ per_stage_cols.append("[dim]—[/dim]")
192
+ continue
193
+ latest = discover_latest_versions(stage_dir)
194
+ if not latest:
195
+ per_stage_cols.append("[dim]empty[/dim]")
196
+ continue
197
+ final_count = sum(
198
+ 1 for pid in latest if (stage_dir / f"{pid}-FINAL.md").exists()
199
+ )
200
+ v_max = max(
201
+ int(re.search(r"v(\d+)", p.name).group(1)) # type: ignore[union-attr]
202
+ for p in latest.values()
203
+ )
204
+ sealed_note = f" ([green]{final_count} sealed[/green])" if final_count else ""
205
+ per_stage_cols.append(f"v{v_max}{sealed_note}")
206
+ ftable.add_row(str(i), d.name, *per_stage_cols)
207
+ console.print(ftable)
208
+
209
+
210
+ @main.command()
211
+ @click.option("--force", is_flag=True, help="Overwrite an existing .paircode/ dir")
212
+ def init(force: bool) -> None:
213
+ """Bootstrap .paircode/ in the current directory."""
214
+ try:
215
+ state = init_paircode(force=force)
216
+ except FileExistsError as exc:
217
+ console.print(f"[red]{exc}[/red]")
218
+ return
219
+ console.print(
220
+ f"[green]✓[/green] Initialized [cyan]{state.root}[/cyan]\n"
221
+ f" wrote {state.journey_path.name}\n"
222
+ f" wrote {state.peers_path.name}\n"
223
+ "\nNext: [cyan]paircode handshake[/cyan] to detect LLM CLIs and propose a peer roster,"
224
+ "\nor [cyan]paircode focus <name>[/cyan] to open your first focus."
225
+ )
226
+
227
+
228
+ @main.command()
229
+ @click.argument("name", required=False)
230
+ @click.option("--prompt", "-p", default=None, help="One-line focus prompt (freeform)")
231
+ def focus(name: str | None, prompt: str | None) -> None:
232
+ """Open a new focus, or list existing focuses if no name given."""
233
+ state = find_paircode()
234
+ if state is None:
235
+ console.print(
236
+ "[red]No .paircode/ found.[/red] Run [cyan]paircode init[/cyan] first."
237
+ )
238
+ return
239
+ if not name:
240
+ if not state.focus_dirs:
241
+ console.print("[yellow]No focuses yet.[/yellow]")
242
+ return
243
+ for d in state.focus_dirs:
244
+ marker = "→" if d == state.active_focus else " "
245
+ console.print(f" {marker} {d.name}")
246
+ return
247
+ try:
248
+ focus_dir = open_focus(state, name, prompt=prompt)
249
+ except FileExistsError as exc:
250
+ console.print(f"[red]{exc}[/red]")
251
+ return
252
+ note_focus_opened(state, focus_dir.name)
253
+ console.print(f"[green]✓[/green] Opened focus [cyan]{focus_dir.name}[/cyan]")
254
+ console.print(f" Edit: [dim]{focus_dir}/FOCUS.md[/dim]")
255
+
256
+
257
+ @main.command()
258
+ @click.argument("stage_name", type=click.Choice(["research", "plan", "execute"]))
259
+ def seal(stage_name: str) -> None:
260
+ """Seal the given stage on the active focus: copy each peer's latest version to {peer}-FINAL.md."""
261
+ state = find_paircode()
262
+ if state is None or state.active_focus is None:
263
+ console.print(
264
+ "[red]No active focus.[/red] Run [cyan]paircode focus <name>[/cyan] first."
265
+ )
266
+ return
267
+ stage_dir = state.active_focus / stage_name
268
+ if not stage_dir.exists():
269
+ console.print(f"[red]Stage dir not found: {stage_dir}[/red]")
270
+ return
271
+ sealed = seal_stage(stage_dir)
272
+ if not sealed:
273
+ console.print(
274
+ f"[yellow]No versioned files found in {stage_dir} — nothing to seal.[/yellow]"
275
+ )
276
+ return
277
+ for s in sealed:
278
+ console.print(
279
+ f" [green]✓[/green] {s.peer_id}: {s.source.name} → [cyan]{s.final.name}[/cyan]"
280
+ )
281
+
282
+
283
+ @main.command()
284
+ @click.argument("stage_name", type=click.Choice(["research", "plan", "execute"]))
285
+ @click.option("--rounds", default=1, show_default=True, help="Rounds to run (round 1 = cold v1; 2+ = review+revise)")
286
+ @click.option("--alpha-cli", default="claude", show_default=True)
287
+ @click.option("--alpha-model", default=None)
288
+ @click.option("--timeout", default=600, show_default=True)
289
+ def stage(stage_name: str, rounds: int, alpha_cli: str, alpha_model: str | None, timeout: int) -> None:
290
+ """Run a peer-review stage on the active focus for N rounds."""
291
+ from paircode.state import find_paircode
292
+
293
+ state = find_paircode()
294
+ if state is None or state.active_focus is None:
295
+ console.print("[red]No active focus.[/red] Run [cyan]paircode focus <name>[/cyan] first.")
296
+ return
297
+ focus_dir = state.active_focus
298
+ focus_md = focus_dir / "FOCUS.md"
299
+ topic = focus_md.read_text(encoding="utf-8") if focus_md.exists() else focus_dir.name
300
+
301
+ console.print(
302
+ f"[bold]stage[/bold] {stage_name} × {rounds} rounds on [cyan]{focus_dir.name}[/cyan]"
303
+ )
304
+ results = run_stage(
305
+ topic=topic,
306
+ focus_dir=focus_dir,
307
+ stage=stage_name, # type: ignore[arg-type]
308
+ rounds=rounds,
309
+ alpha_cli=alpha_cli,
310
+ alpha_model=alpha_model,
311
+ timeout_s=timeout,
312
+ state=state,
313
+ )
314
+ _render_stage_results(stage_name, results)
315
+
316
+
317
+ def _render_stage_results(stage_name: str, results) -> None:
318
+ for sr in results:
319
+ console.print(f"\n[bold]round {sr.version}[/bold] ({stage_name})")
320
+ table = Table(show_header=True, header_style="bold")
321
+ table.add_column("kind")
322
+ table.add_column("peer")
323
+ table.add_column("cli")
324
+ table.add_column("ok")
325
+ table.add_column("duration")
326
+ if sr.peer_results:
327
+ for r in sr.peer_results:
328
+ status = "[green]✓[/green]" if r.ok else "[red]✗[/red]"
329
+ table.add_row("cold", r.peer_id, r.cli, status, f"{r.duration_s:.1f}s")
330
+ if sr.review_results:
331
+ for r in sr.review_results:
332
+ status = "[green]✓[/green]" if r.ok else "[red]✗[/red]"
333
+ table.add_row("review", r.peer_id, r.cli, status, f"{r.duration_s:.1f}s")
334
+ if sr.alpha_revision:
335
+ r = sr.alpha_revision
336
+ status = "[green]✓[/green]" if r.ok else "[red]✗[/red]"
337
+ table.add_row("revise", r.peer_id, r.cli, status, f"{r.duration_s:.1f}s")
338
+ console.print(table)
339
+
340
+
341
+ @main.command()
342
+ @click.argument("topic")
343
+ @click.option(
344
+ "--alpha-cli",
345
+ default="claude",
346
+ show_default=True,
347
+ help="Which LLM CLI acts as alpha (the primary developer)",
348
+ )
349
+ @click.option("--alpha-model", default=None, help="Model string for alpha (CLI default if omitted)")
350
+ @click.option("--timeout", default=600, show_default=True, help="Per-peer timeout in seconds")
351
+ @click.option(
352
+ "--research-rounds",
353
+ default=2,
354
+ show_default=True,
355
+ help="Rounds in research stage (1 = cold only, 2+ = with reviews)",
356
+ )
357
+ @click.option("--plan-rounds", default=2, show_default=True)
358
+ @click.option("--execute-rounds", default=1, show_default=True)
359
+ @click.option(
360
+ "--research-only",
361
+ is_flag=True,
362
+ help="Stop after research stage (for quick iteration / cost control)",
363
+ )
364
+ def drive(
365
+ topic: str,
366
+ alpha_cli: str,
367
+ alpha_model: str | None,
368
+ timeout: int,
369
+ research_rounds: int,
370
+ plan_rounds: int,
371
+ execute_rounds: int,
372
+ research_only: bool,
373
+ ) -> None:
374
+ """Full loop: open focus, run research → plan → execute with peer review."""
375
+ console.print(f"[bold]paircode drive[/bold] topic=[cyan]{topic}[/cyan]")
376
+ console.print(
377
+ f" alpha = {alpha_cli} ({alpha_model or 'default model'}), timeout = {timeout}s"
378
+ )
379
+ if research_only:
380
+ console.print(
381
+ f" [dim]research-only mode, rounds = {research_rounds}[/dim]"
382
+ )
383
+ stage_results = drive_research(
384
+ topic=topic,
385
+ alpha_cli=alpha_cli,
386
+ alpha_model=alpha_model,
387
+ timeout_s=timeout,
388
+ rounds=research_rounds,
389
+ )
390
+ _render_stage_results("research", stage_results)
391
+ console.print(
392
+ f"\n[bold]Research stage complete.[/bold] "
393
+ f"Files at [cyan].paircode/{stage_results[0].focus_dir.name}/research/[/cyan]"
394
+ )
395
+ return
396
+
397
+ console.print(
398
+ f" [dim]research={research_rounds}r, plan={plan_rounds}r, "
399
+ f"execute={execute_rounds}r (this can take a while)[/dim]"
400
+ )
401
+ all_results = drive_full(
402
+ topic=topic,
403
+ alpha_cli=alpha_cli,
404
+ alpha_model=alpha_model,
405
+ timeout_s=timeout,
406
+ research_rounds=research_rounds,
407
+ plan_rounds=plan_rounds,
408
+ execute_rounds=execute_rounds,
409
+ )
410
+ for stage_name, stage_results in all_results.items():
411
+ _render_stage_results(stage_name, stage_results)
412
+ first_focus = next(iter(all_results.values()))[0].focus_dir
413
+ console.print(
414
+ f"\n[bold green]Drive complete.[/bold green] Focus: [cyan]{first_focus.name}[/cyan]"
415
+ )
416
+
417
+
418
+ if __name__ == "__main__":
419
+ main()
paircode/detect.py ADDED
@@ -0,0 +1,52 @@
1
+ """Detect installed LLM CLIs and their config directories."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class CliInfo:
11
+ name: str
12
+ binary: str
13
+ binary_path: Path | None
14
+ config_dir: Path
15
+ installed: bool
16
+ install_hint: str # what to tell the user if they want to install this CLI
17
+
18
+
19
+ KNOWN_CLIS: dict[str, tuple[str, Path, str]] = {
20
+ # name: (binary, config_dir, install_hint)
21
+ "claude": (
22
+ "claude",
23
+ Path.home() / ".claude",
24
+ "Install Claude Code: https://claude.com/product/claude-code",
25
+ ),
26
+ "codex": (
27
+ "codex",
28
+ Path.home() / ".codex",
29
+ "Install Codex CLI: `npm i -g @openai/codex`",
30
+ ),
31
+ "gemini": (
32
+ "gemini",
33
+ Path.home() / ".gemini",
34
+ "Install Gemini CLI: `npm i -g @google/gemini-cli`",
35
+ ),
36
+ }
37
+
38
+
39
+ def detect_all() -> dict[str, CliInfo]:
40
+ """Return a dict of name -> CliInfo for every known LLM CLI we care about."""
41
+ result: dict[str, CliInfo] = {}
42
+ for name, (binary, config_dir, hint) in KNOWN_CLIS.items():
43
+ found = shutil.which(binary)
44
+ result[name] = CliInfo(
45
+ name=name,
46
+ binary=binary,
47
+ binary_path=Path(found) if found else None,
48
+ config_dir=config_dir,
49
+ installed=bool(found),
50
+ install_hint=hint,
51
+ )
52
+ return result