paircode 0.11.7__tar.gz → 0.12.1__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.
Files changed (32) hide show
  1. {paircode-0.11.7 → paircode-0.12.1}/PKG-INFO +1 -1
  2. {paircode-0.11.7 → paircode-0.12.1}/pyproject.toml +1 -1
  3. {paircode-0.11.7 → paircode-0.12.1}/src/paircode/__init__.py +1 -1
  4. {paircode-0.11.7 → paircode-0.12.1}/src/paircode/cli.py +146 -0
  5. {paircode-0.11.7 → paircode-0.12.1}/src/paircode/installer.py +10 -4
  6. paircode-0.12.1/src/paircode/peerlab.py +194 -0
  7. paircode-0.12.1/src/paircode/templates/claude/commands/peerlab.md +71 -0
  8. paircode-0.12.1/tests/test_peerlab.py +242 -0
  9. {paircode-0.11.7 → paircode-0.12.1}/.gitignore +0 -0
  10. {paircode-0.11.7 → paircode-0.12.1}/LICENSE +0 -0
  11. {paircode-0.11.7 → paircode-0.12.1}/README.md +0 -0
  12. {paircode-0.11.7 → paircode-0.12.1}/diary/001-step-a-architecture.md +0 -0
  13. {paircode-0.11.7 → paircode-0.12.1}/diary/002-v0.10-release-pipeline.md +0 -0
  14. {paircode-0.11.7 → paircode-0.12.1}/diary/003-arch-b-pivot-grappling.md +0 -0
  15. {paircode-0.11.7 → paircode-0.12.1}/src/paircode/__main__.py +0 -0
  16. {paircode-0.11.7 → paircode-0.12.1}/src/paircode/converge.py +0 -0
  17. {paircode-0.11.7 → paircode-0.12.1}/src/paircode/detect.py +0 -0
  18. {paircode-0.11.7 → paircode-0.12.1}/src/paircode/handshake.py +0 -0
  19. {paircode-0.11.7 → paircode-0.12.1}/src/paircode/runner.py +0 -0
  20. {paircode-0.11.7 → paircode-0.12.1}/src/paircode/state.py +0 -0
  21. {paircode-0.11.7 → paircode-0.12.1}/src/paircode/templates/FOCUS.md +0 -0
  22. {paircode-0.11.7 → paircode-0.12.1}/src/paircode/templates/JOURNEY.md +0 -0
  23. {paircode-0.11.7 → paircode-0.12.1}/src/paircode/templates/claude/commands/paircode.md +0 -0
  24. {paircode-0.11.7 → paircode-0.12.1}/src/paircode/templates/codex/commands/paircode.md +0 -0
  25. {paircode-0.11.7 → paircode-0.12.1}/src/paircode/templates/gemini/commands/paircode.toml +0 -0
  26. {paircode-0.11.7 → paircode-0.12.1}/src/paircode/templates/peers.yaml +0 -0
  27. {paircode-0.11.7 → paircode-0.12.1}/src/paircode/util.py +0 -0
  28. {paircode-0.11.7 → paircode-0.12.1}/tests/__init__.py +0 -0
  29. {paircode-0.11.7 → paircode-0.12.1}/tests/test_cli_smoke.py +0 -0
  30. {paircode-0.11.7 → paircode-0.12.1}/tests/test_converge.py +0 -0
  31. {paircode-0.11.7 → paircode-0.12.1}/tests/test_smoke.py +0 -0
  32. {paircode-0.11.7 → paircode-0.12.1}/tests/test_state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paircode
3
- Version: 0.11.7
3
+ Version: 0.12.1
4
4
  Summary: Adversarial journey framework — orchestrate multiple LLM peers through research, plan, and execute stages with file-trace peer review.
5
5
  Project-URL: Homepage, https://github.com/starshipagentic/paircode
6
6
  Project-URL: Repository, https://github.com/starshipagentic/paircode
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "paircode"
7
- version = "0.11.7"
7
+ version = "0.12.1"
8
8
  description = "Adversarial journey framework — orchestrate multiple LLM peers through research, plan, and execute stages with file-trace peer review."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -2,4 +2,4 @@
2
2
 
3
3
  See README.md and diary/001-step-a-architecture.md for the full design.
4
4
  """
5
- __version__ = "0.11.6"
5
+ __version__ = "0.12.0"
@@ -312,6 +312,152 @@ def invoke(peer_id: str, prompt: str, out_path: str, timeout: int, fast: bool) -
312
312
  raise SystemExit(1)
313
313
 
314
314
 
315
+ # ---------------------------------------------------------------------------
316
+ # peerlab — per-peer independent parallel labs with their own .git
317
+ # ---------------------------------------------------------------------------
318
+
319
+ @main.group()
320
+ def peerlab() -> None:
321
+ """Per-peer parallel labs under .peerlab/<peer-id>/ with own .git each."""
322
+
323
+
324
+ @peerlab.command("ensure")
325
+ def peerlab_ensure() -> None:
326
+ """Scaffold .peerlab/<peer-id>/ per peer; seed from project root on first
327
+ creation; git init per lab; add .peerlab/ to outer .gitignore. Idempotent."""
328
+ from paircode.peerlab import ensure_peer_labs
329
+
330
+ state = find_paircode()
331
+ if state is None:
332
+ state = init_paircode()
333
+ results = ensure_peer_labs(state)
334
+ for r in results:
335
+ if r.status == "created":
336
+ click.echo(f"created {r.lab_path}")
337
+ elif r.status == "missing-id":
338
+ click.echo(f"skipped peer with no id", err=True)
339
+ # "already-exists" → silent, per auto-install ethos
340
+
341
+
342
+ @peerlab.command("invoke")
343
+ @click.argument("peer_id")
344
+ @click.argument("prompt")
345
+ @click.option("--out", "out_path", default=None, type=click.Path(),
346
+ help="Optional file-trace output (markdown). If omitted, peer stdout goes to stdout.")
347
+ @click.option("--timeout", default=600, show_default=True,
348
+ help="Per-peer timeout in seconds")
349
+ @click.option("--fast/--no-fast", default=False,
350
+ help="Apply cliworker speed flags")
351
+ def peerlab_invoke(peer_id: str, prompt: str, out_path: str | None, timeout: int, fast: bool) -> None:
352
+ """Fire a peer CLI with cwd set to its lab. Peer works in-place."""
353
+ from paircode.peerlab import peer_lab_path
354
+
355
+ state = find_paircode()
356
+ if state is None:
357
+ raise click.ClickException(
358
+ "No .paircode/ found. Run `paircode ensure-scaffold` + `paircode peerlab ensure` first."
359
+ )
360
+ peers = read_peers(state)
361
+ peer = next((p for p in peers if p.get("id") == peer_id), None)
362
+ if peer is None:
363
+ known = [p.get("id") for p in peers]
364
+ raise click.ClickException(f"Unknown peer id: {peer_id!r}. Known: {known}")
365
+ lab = peer_lab_path(state, peer_id)
366
+ if not lab.exists():
367
+ raise click.ClickException(
368
+ f"Lab dir missing: {lab}. Run `paircode peerlab ensure` first."
369
+ )
370
+
371
+ # Preamble so the peer knows it's in a lab with its own git
372
+ framed_prompt = (
373
+ f"You are the {peer_id} peer working in your own lab at {lab}.\n"
374
+ f"Your cwd is already this lab. You have your own `.git/` here — commit\n"
375
+ f"your work (stage + commit) before finishing so the team lead can read\n"
376
+ f"the diff. Don't worry about alpha's repo; your lab is fully independent.\n"
377
+ f"\n"
378
+ f"Work:\n"
379
+ f"{prompt}"
380
+ )
381
+
382
+ from cliworker import get_spec, run as cliworker_run
383
+
384
+ spec = get_spec(str(peer.get("cli")), model=peer.get("model")) if peer.get("model") else get_spec(str(peer.get("cli")))
385
+ results = cliworker_run(
386
+ framed_prompt, spec,
387
+ fast=True if fast else None,
388
+ timeout_s=timeout,
389
+ cwd=str(lab),
390
+ )
391
+ cli_result = results[-1] if results else None
392
+ if cli_result is None:
393
+ raise click.ClickException(f"no result from cliworker for {peer.get('cli')}")
394
+
395
+ status = "ok" if cli_result.ok else "FAIL"
396
+ click.echo(f"{peer_id} {status} {cli_result.duration_s:.1f}s", err=True)
397
+
398
+ body = cli_result.stdout if cli_result.ok else (
399
+ f"# Peer run FAILED\n\n```\n{cli_result.stderr}\n```\n\n{cli_result.stdout}"
400
+ )
401
+
402
+ if out_path:
403
+ header = (
404
+ f"<!-- peer_id: {peer_id} -->\n"
405
+ f"<!-- cli: {peer.get('cli')} -->\n"
406
+ f"<!-- lab: {lab} -->\n"
407
+ f"<!-- duration_s: {cli_result.duration_s:.1f} -->\n"
408
+ f"<!-- ok: {cli_result.ok} -->\n"
409
+ )
410
+ timeout_kind = getattr(cli_result, "timeout_kind", None)
411
+ if timeout_kind:
412
+ header += f"<!-- timeout_kind: {timeout_kind} -->\n"
413
+ header += "\n"
414
+ Path(out_path).parent.mkdir(parents=True, exist_ok=True)
415
+ Path(out_path).write_text(header + body, encoding="utf-8")
416
+ else:
417
+ click.echo(body)
418
+
419
+ if not cli_result.ok:
420
+ raise SystemExit(1)
421
+
422
+
423
+ @peerlab.command("list")
424
+ def peerlab_list() -> None:
425
+ """Show peer labs with creation status + HEAD commit (if any)."""
426
+ from paircode.peerlab import peer_lab_path
427
+
428
+ state = find_paircode()
429
+ if state is None:
430
+ click.echo("No .paircode/ found. Nothing to list.")
431
+ return
432
+ peers = read_peers(state)
433
+ if not peers:
434
+ click.echo("No peers in roster.")
435
+ return
436
+ ptable = Table(show_header=True, header_style="bold")
437
+ ptable.add_column("peer")
438
+ ptable.add_column("lab path")
439
+ ptable.add_column("git HEAD")
440
+ import subprocess as _sub
441
+ for p in peers:
442
+ pid = p.get("id")
443
+ if not pid:
444
+ continue
445
+ lab = peer_lab_path(state, pid)
446
+ if not lab.exists():
447
+ ptable.add_row(pid, str(lab), "[dim](not created)[/dim]")
448
+ continue
449
+ if not (lab / ".git").exists():
450
+ ptable.add_row(pid, str(lab), "[yellow](no git)[/yellow]")
451
+ continue
452
+ r = _sub.run(
453
+ ["git", "-C", str(lab), "log", "-1", "--oneline"],
454
+ capture_output=True, text=True, check=False,
455
+ )
456
+ head = r.stdout.strip() if r.returncode == 0 and r.stdout.strip() else "[dim](no commits)[/dim]"
457
+ ptable.add_row(pid, str(lab), head)
458
+ console.print(ptable)
459
+
460
+
315
461
  # ---------------------------------------------------------------------------
316
462
  # Bare paircode — show state
317
463
  # ---------------------------------------------------------------------------
@@ -64,14 +64,19 @@ def install_claude(info: CliInfo) -> InstallResult:
64
64
  )
65
65
  commands_dir = info.config_dir / "commands"
66
66
  commands_dir.mkdir(parents=True, exist_ok=True)
67
- target = commands_dir / "paircode.md"
68
- target.write_text(
67
+ paircode_target = commands_dir / "paircode.md"
68
+ paircode_target.write_text(
69
69
  _read_template("claude/commands/paircode.md"),
70
70
  encoding="utf-8",
71
71
  )
72
+ peerlab_target = commands_dir / "peerlab.md"
73
+ peerlab_target.write_text(
74
+ _read_template("claude/commands/peerlab.md"),
75
+ encoding="utf-8",
76
+ )
72
77
  return InstallResult(
73
- cli_name="claude", action="installed", path=target,
74
- message=f"Wrote /paircode slash command to {target}.",
78
+ cli_name="claude", action="installed", path=paircode_target,
79
+ message=f"Wrote /paircode + /peerlab slash commands to {commands_dir}.",
75
80
  )
76
81
 
77
82
 
@@ -221,6 +226,7 @@ def uninstall_all() -> list[InstallResult]:
221
226
  claude_home = Path.home() / ".claude"
222
227
  claude_paths = [
223
228
  claude_home / "commands" / "paircode.md",
229
+ claude_home / "commands" / "peerlab.md",
224
230
  claude_home / "agents" / "paircode-peer.md",
225
231
  ]
226
232
  removed = []
@@ -0,0 +1,194 @@
1
+ """Per-peer independent labs — each peer owns a parallel implementation
2
+ of the project, with its own `.git/` for independent history.
3
+
4
+ Laid out under `.peerlab/<peer-id>/` at the project root, gitignored from
5
+ the outer repo. Seeding is one-time on first creation (rsync from project
6
+ root minus a standard exclude list). After that, each peer's lab evolves
7
+ independently — the peer commits, diffs, experiments in its own git.
8
+
9
+ Used by the `/peerlab` slash command (separate concept from `/paircode`):
10
+ team lead fires each peer with cwd = its lab, peers do real work and commit,
11
+ team lead reads the resulting diffs and synthesizes.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import shutil
16
+ import subprocess
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Iterable
20
+
21
+ from paircode.state import PaircodeState, read_peers
22
+
23
+
24
+ PEERLAB_DIRNAME = ".peerlab"
25
+
26
+ SEED_EXCLUDES: tuple[str, ...] = (
27
+ ".git",
28
+ ".peerlab",
29
+ ".paircode",
30
+ "__pycache__",
31
+ ".venv", "venv", "env", "ENV",
32
+ "node_modules",
33
+ ".pytest_cache", ".tox", ".mypy_cache", ".ruff_cache",
34
+ "build", "dist", "wheels",
35
+ "*.egg-info",
36
+ ".DS_Store",
37
+ ".coverage", ".coverage.*",
38
+ "*.pyc",
39
+ "htmlcov",
40
+ )
41
+
42
+ GITIGNORE_BLOCK_HEADER = "# peerlab — per-peer independent parallel labs"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class EnsureResult:
47
+ peer_id: str
48
+ lab_path: Path
49
+ status: str # "created" | "already-exists" | "missing-id"
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Helpers — gitignore, rsync, git init, initial commit
54
+ # ---------------------------------------------------------------------------
55
+
56
+ def ensure_gitignore(project_root: Path) -> bool:
57
+ """Append `.peerlab/` to outer .gitignore if not already present.
58
+
59
+ Returns True if the line was added (or the file was created), False if
60
+ it was already there.
61
+ """
62
+ gitignore = project_root / ".gitignore"
63
+ existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
64
+ for line in existing.splitlines():
65
+ s = line.strip()
66
+ if s in (".peerlab/", ".peerlab"):
67
+ return False
68
+ tail = existing.rstrip()
69
+ new_text = (tail + "\n\n" if tail else "") + GITIGNORE_BLOCK_HEADER + "\n.peerlab/\n"
70
+ gitignore.write_text(new_text, encoding="utf-8")
71
+ return True
72
+
73
+
74
+ def _rsync_project(src: Path, dst: Path, excludes: Iterable[str] = SEED_EXCLUDES) -> None:
75
+ """Copy src → dst with standard excludes. Uses rsync if available, falls
76
+ back to `shutil.copytree` with an ignore function."""
77
+ dst.mkdir(parents=True, exist_ok=True)
78
+ rsync = shutil.which("rsync")
79
+ if rsync:
80
+ args = [rsync, "-a"]
81
+ for e in excludes:
82
+ args.extend(["--exclude", e])
83
+ args.extend([str(src) + "/", str(dst) + "/"])
84
+ subprocess.run(args, check=True)
85
+ return
86
+
87
+ # Pure-Python fallback: copytree with an ignore func.
88
+ exclude_set = set(excludes)
89
+
90
+ def _ignore(_directory: str, names: list[str]) -> list[str]:
91
+ ignored = []
92
+ for n in names:
93
+ # Match exact name or a simple *-prefix/suffix wildcard
94
+ if n in exclude_set:
95
+ ignored.append(n)
96
+ continue
97
+ for pat in exclude_set:
98
+ if pat.startswith("*") and n.endswith(pat[1:]):
99
+ ignored.append(n); break
100
+ if pat.endswith("*") and n.startswith(pat[:-1]):
101
+ ignored.append(n); break
102
+ return ignored
103
+
104
+ # copytree fails if dst exists — but our dst was just mkdir'd.
105
+ # Remove empty dst and let copytree re-create.
106
+ if dst.exists() and not any(dst.iterdir()):
107
+ dst.rmdir()
108
+ shutil.copytree(src, dst, ignore=_ignore)
109
+
110
+
111
+ def _git_init(lab: Path) -> None:
112
+ """`git init` inside `lab/`. No-op if already inited."""
113
+ if (lab / ".git").exists():
114
+ return
115
+ # Try modern flag first, fall back for older git.
116
+ result = subprocess.run(
117
+ ["git", "init", "--quiet", "--initial-branch=main", str(lab)],
118
+ capture_output=True, text=True, check=False,
119
+ )
120
+ if result.returncode != 0 or not (lab / ".git").exists():
121
+ subprocess.run(["git", "init", "--quiet", str(lab)], check=True)
122
+
123
+
124
+ def _git_initial_commit(lab: Path, message: str = "initial seed from project root") -> bool:
125
+ """Stage everything + commit. Returns True if a commit was made.
126
+
127
+ Sets local-only user.name/email (doesn't touch global git config) so the
128
+ commit is authored even on machines without a global identity.
129
+ """
130
+ if not any(lab.iterdir()):
131
+ return False
132
+ # Local git config — scoped to this repo
133
+ subprocess.run(["git", "-C", str(lab), "config", "user.name", "paircode-peerlab"], check=False)
134
+ subprocess.run(["git", "-C", str(lab), "config", "user.email", "peerlab@paircode.local"], check=False)
135
+ subprocess.run(["git", "-C", str(lab), "add", "-A"], check=False)
136
+ # Commit only if there's something staged
137
+ diff_check = subprocess.run(
138
+ ["git", "-C", str(lab), "diff", "--cached", "--quiet"],
139
+ check=False,
140
+ )
141
+ if diff_check.returncode == 0:
142
+ return False # nothing staged
143
+ subprocess.run(
144
+ ["git", "-C", str(lab), "commit", "--quiet", "-m", message],
145
+ check=False,
146
+ )
147
+ return True
148
+
149
+
150
+ # ---------------------------------------------------------------------------
151
+ # Public — ensure per-peer labs
152
+ # ---------------------------------------------------------------------------
153
+
154
+ def ensure_peer_labs(state: PaircodeState) -> list[EnsureResult]:
155
+ """Scaffold `.peerlab/<peer-id>/` for every peer in `peers.yaml`.
156
+
157
+ Idempotent. On first creation of a lab:
158
+ - mkdir `.peerlab/<peer-id>/`
159
+ - rsync project root → lab (minus SEED_EXCLUDES)
160
+ - `git init` inside lab (own repo)
161
+ - initial commit so HEAD exists → peers can `git diff HEAD~1` immediately
162
+
163
+ On subsequent calls: skip labs that already have `.git/` — never re-seed.
164
+ Also ensures the outer `.gitignore` contains `.peerlab/` so the outer repo
165
+ doesn't accidentally track peer labs.
166
+ """
167
+ project_root = state.project_root
168
+ peerlab_root = project_root / PEERLAB_DIRNAME
169
+ peerlab_root.mkdir(exist_ok=True)
170
+
171
+ ensure_gitignore(project_root)
172
+
173
+ results: list[EnsureResult] = []
174
+ for p in read_peers(state):
175
+ pid = p.get("id") if isinstance(p, dict) else getattr(p, "id", None)
176
+ if not pid:
177
+ results.append(EnsureResult(peer_id="?", lab_path=peerlab_root, status="missing-id"))
178
+ continue
179
+ lab = peerlab_root / pid
180
+ if (lab / ".git").exists():
181
+ results.append(EnsureResult(peer_id=pid, lab_path=lab, status="already-exists"))
182
+ continue
183
+ lab.mkdir(exist_ok=True)
184
+ # Seed from project root (might be empty for greenfield — rsync handles fine)
185
+ _rsync_project(project_root, lab)
186
+ _git_init(lab)
187
+ _git_initial_commit(lab, message="initial seed from project root")
188
+ results.append(EnsureResult(peer_id=pid, lab_path=lab, status="created"))
189
+ return results
190
+
191
+
192
+ def peer_lab_path(state: PaircodeState, peer_id: str) -> Path:
193
+ """Return the absolute lab path for a peer id. Does not create."""
194
+ return state.project_root / PEERLAB_DIRNAME / peer_id
@@ -0,0 +1,71 @@
1
+ ---
2
+ description: peerlab — each peer does real work in its own parallel lab with its own git; team lead reads the diffs and synthesizes.
3
+ ---
4
+
5
+ You are the team lead for a `/peerlab` run. Each peer has its own persistent lab at `.peerlab/<peer-id>/` with its own `.git/`. Peers **own** their labs — they write code, run tests, commit as they go. Your job: fire every peer on the user's prompt, read what they actually did (the diffs), and synthesize.
6
+
7
+ This is a separate concept from `/paircode` — no focus dirs, no stages, no markdown consensus files. The deliverable is real code evolution per lab + your synthesis at the end.
8
+
9
+ `$ARGUMENTS` is the user's raw prompt. Pass it through verbatim.
10
+
11
+ ## Step 1 — Bootstrap (silent via Bash)
12
+
13
+ ```bash
14
+ paircode peerlab ensure # scaffolds + seeds + git-inits each lab (idempotent)
15
+ PEERS=$(paircode roster --alpha claude)
16
+ ```
17
+
18
+ `paircode peerlab ensure` is safe to run every time. First time: copies the project root into each peer's lab (minus standard excludes) and `git init`s it. Subsequent times: no-op.
19
+
20
+ ## Step 2 — Fire every peer in parallel
21
+
22
+ For each peer-id in `$PEERS`, spawn one subagent via the Agent tool (`subagent_type=general-purpose`, `run_in_background=true`) — all in a single message so they run concurrently.
23
+
24
+ Each subagent's prompt:
25
+
26
+ ```
27
+ You are monitoring the {peer-id} peer. Run this shell command and wait:
28
+
29
+ paircode peerlab invoke {peer-id} "<the user's prompt verbatim>"
30
+
31
+ That fires the peer with its cwd set to .peerlab/{peer-id}/ (its own lab).
32
+ The peer is expected to commit its work in its own .git before finishing.
33
+
34
+ After it returns, gather:
35
+ 1. The peer's stdout (its narrative of what it did).
36
+ 2. `git -C .peerlab/{peer-id} log --oneline -5` — recent commits.
37
+ 3. `git -C .peerlab/{peer-id} diff HEAD~1 HEAD --stat` — what changed.
38
+
39
+ Report back to team lead in under 400 words: peer-id, ok=yes/no, duration,
40
+ 1-line summary of what the peer claims it did, plus the git stat output.
41
+ ```
42
+
43
+ ## Step 3 — Read the actual code each peer wrote
44
+
45
+ When every peer has reported, for each peer-id:
46
+
47
+ ```bash
48
+ git -C .peerlab/<peer-id> log --oneline -5
49
+ git -C .peerlab/<peer-id> diff HEAD~1 HEAD
50
+ ```
51
+
52
+ Read the actual diffs (use the Read tool on the changed files inside each lab, or just the full `git diff` output). This is the real content — NOT the peer's narrative in stdout. Trust the code over the claims.
53
+
54
+ ## Step 4 — Synthesize (one message to user, ≤300 words)
55
+
56
+ Produce a tight team-lead synthesis:
57
+
58
+ - **Per-peer summary**: 1–2 lines each — files touched, LOC delta, the shape of the approach.
59
+ - **Cross-cutting themes**: did multiple peers hit the same issue/solution? That's signal.
60
+ - **Honest head-to-head**: which lab made the cleanest move? Which went off-track? Don't play favorites but don't hedge.
61
+ - **What to pull into alpha**: if any peer's approach is objectively better than what alpha would do, name the files + the approach. Don't do the pull yourself — just recommend.
62
+
63
+ No focus dirs. No consensus.md. No markdown artifacts on disk. Just the synthesis as your reply to the user.
64
+
65
+ ## Guardrails
66
+
67
+ - **Alpha (this session) does NOT edit `.peerlab/<peer-id>/`.** Peers own their labs. You only read.
68
+ - **Alpha does NOT implement the user's prompt in the project root.** That's a `/paircode` job or a direct Claude Code ask. `/peerlab` is strictly "peers go build it, I read the diffs."
69
+ - **Fire every peer** unless the user's prompt explicitly names `--peer`/`--peers`. Watchdog protects against hangs.
70
+ - **If a peer fails** (hung, errored, no commits), note it in the synthesis and continue. One flaky peer doesn't sink the run.
71
+ - **No AI attribution** in anything you write — see the maintainer's CLAUDE.md.
@@ -0,0 +1,242 @@
1
+ """Tests for paircode.peerlab — per-peer parallel labs with own .git."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+ from click.testing import CliRunner
10
+
11
+ from paircode.cli import main
12
+ from paircode.handshake import ProposedPeer
13
+ from paircode.peerlab import (
14
+ PEERLAB_DIRNAME,
15
+ ensure_gitignore,
16
+ ensure_peer_labs,
17
+ peer_lab_path,
18
+ )
19
+ from paircode.state import find_paircode, init_paircode
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # ensure_peer_labs — scaffold + seed + git init + initial commit
24
+ # ---------------------------------------------------------------------------
25
+
26
+ def _seed_roster_with(tmp_path: Path, monkeypatch) -> None:
27
+ """Make propose_roster return codex + gemini so ensure-scaffold populates
28
+ peers.yaml with both, creating their sandbox dirs too."""
29
+ peers = [
30
+ ProposedPeer(id="peer-a-codex", cli="codex", priority="high", notes=""),
31
+ ProposedPeer(id="peer-b-gemini", cli="gemini", priority="low", notes=""),
32
+ ]
33
+ monkeypatch.setattr("paircode.cli.propose_roster", lambda: peers)
34
+
35
+
36
+ def _init_and_scaffold(tmp_path: Path, monkeypatch) -> None:
37
+ """Seed a .paircode/ + populated project root in tmp_path."""
38
+ # Simulate a small existing project at tmp_path
39
+ (tmp_path / "src").mkdir()
40
+ (tmp_path / "src" / "app.py").write_text("print('hi')\n")
41
+ (tmp_path / "README.md").write_text("# demo\n")
42
+ monkeypatch.chdir(tmp_path)
43
+ _seed_roster_with(tmp_path, monkeypatch)
44
+ runner = CliRunner()
45
+ runner.invoke(main, ["ensure-scaffold"])
46
+
47
+
48
+ def test_ensure_peer_labs_creates_dirs_and_seeds_and_inits_git(tmp_path, monkeypatch):
49
+ """First run: each peer gets a lab, seeded from project root, with own
50
+ .git/ and an initial commit."""
51
+ _init_and_scaffold(tmp_path, monkeypatch)
52
+ state = find_paircode()
53
+ results = ensure_peer_labs(state)
54
+ assert len(results) == 2
55
+ statuses = {r.peer_id: r.status for r in results}
56
+ assert statuses["peer-a-codex"] == "created"
57
+ assert statuses["peer-b-gemini"] == "created"
58
+
59
+ peerlab_root = tmp_path / PEERLAB_DIRNAME
60
+ for pid in ("peer-a-codex", "peer-b-gemini"):
61
+ lab = peerlab_root / pid
62
+ assert lab.is_dir(), f"{lab} missing"
63
+ # Seeded contents
64
+ assert (lab / "src" / "app.py").is_file()
65
+ assert (lab / "README.md").is_file()
66
+ # Own git
67
+ assert (lab / ".git").is_dir()
68
+ # Initial commit exists — HEAD resolves
69
+ r = subprocess.run(
70
+ ["git", "-C", str(lab), "log", "-1", "--oneline"],
71
+ capture_output=True, text=True, check=False,
72
+ )
73
+ assert r.returncode == 0
74
+ assert r.stdout.strip(), f"no initial commit in {lab}"
75
+
76
+
77
+ def test_ensure_peer_labs_is_idempotent(tmp_path, monkeypatch):
78
+ """Second call: labs already exist → status 'already-exists', no re-seed."""
79
+ _init_and_scaffold(tmp_path, monkeypatch)
80
+ state = find_paircode()
81
+ ensure_peer_labs(state)
82
+
83
+ # Simulate the peer evolving: write a new file, commit
84
+ lab = peer_lab_path(state, "peer-a-codex")
85
+ (lab / "new-work.py").write_text("# codex added this\n")
86
+ subprocess.run(["git", "-C", str(lab), "add", "-A"], check=False)
87
+ subprocess.run(
88
+ ["git", "-C", str(lab), "-c", "user.name=t", "-c", "user.email=t@t",
89
+ "commit", "--quiet", "-m", "codex evolution"],
90
+ check=False,
91
+ )
92
+
93
+ # Re-run ensure — should NOT wipe codex's evolution
94
+ results = ensure_peer_labs(state)
95
+ statuses = {r.peer_id: r.status for r in results}
96
+ assert statuses["peer-a-codex"] == "already-exists"
97
+ assert statuses["peer-b-gemini"] == "already-exists"
98
+ assert (lab / "new-work.py").exists(), "idempotent run wiped codex's work!"
99
+
100
+
101
+ def test_ensure_gitignore_adds_peerlab_line(tmp_path):
102
+ """First call appends `.peerlab/` to .gitignore; second call no-ops."""
103
+ gitignore = tmp_path / ".gitignore"
104
+ gitignore.write_text("__pycache__/\n.venv/\n")
105
+ assert ensure_gitignore(tmp_path) is True
106
+ text = gitignore.read_text()
107
+ assert ".peerlab/" in text
108
+ assert "peerlab — per-peer" in text
109
+
110
+ # Second call: already there → no change
111
+ before = text
112
+ assert ensure_gitignore(tmp_path) is False
113
+ assert gitignore.read_text() == before
114
+
115
+
116
+ def test_ensure_gitignore_creates_file_if_missing(tmp_path):
117
+ """No .gitignore → create it with the peerlab entry."""
118
+ gitignore = tmp_path / ".gitignore"
119
+ assert not gitignore.exists()
120
+ assert ensure_gitignore(tmp_path) is True
121
+ assert ".peerlab/" in gitignore.read_text()
122
+
123
+
124
+ def test_ensure_peer_labs_greenfield_minimal_seed(tmp_path, monkeypatch):
125
+ """On a near-empty project, labs still get created with git. The only
126
+ non-git content is the `.gitignore` that ensure_peer_labs itself added
127
+ to the outer repo (then rsync copies it into each lab)."""
128
+ monkeypatch.chdir(tmp_path)
129
+ _seed_roster_with(tmp_path, monkeypatch)
130
+ runner = CliRunner()
131
+ runner.invoke(main, ["ensure-scaffold"])
132
+ state = find_paircode()
133
+ results = ensure_peer_labs(state)
134
+ statuses = {r.peer_id: r.status for r in results}
135
+ assert statuses["peer-a-codex"] == "created"
136
+ lab = peer_lab_path(state, "peer-a-codex")
137
+ assert lab.is_dir()
138
+ assert (lab / ".git").is_dir()
139
+ children = sorted(p.name for p in lab.iterdir() if p.name != ".git")
140
+ # Greenfield: only the inherited .gitignore, nothing else (no source files)
141
+ assert children == [".gitignore"], f"expected just .gitignore, got {children}"
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # CLI surface — paircode peerlab ensure / list
146
+ # ---------------------------------------------------------------------------
147
+
148
+ def test_paircode_peerlab_ensure_command(tmp_path, monkeypatch):
149
+ _init_and_scaffold(tmp_path, monkeypatch)
150
+ runner = CliRunner()
151
+ result = runner.invoke(main, ["peerlab", "ensure"])
152
+ assert result.exit_code == 0, result.output
153
+ assert (tmp_path / ".peerlab" / "peer-a-codex").is_dir()
154
+ assert (tmp_path / ".peerlab" / "peer-b-gemini").is_dir()
155
+
156
+
157
+ def test_paircode_peerlab_list_shows_head(tmp_path, monkeypatch):
158
+ _init_and_scaffold(tmp_path, monkeypatch)
159
+ runner = CliRunner()
160
+ runner.invoke(main, ["peerlab", "ensure"])
161
+ result = runner.invoke(main, ["peerlab", "list"])
162
+ assert result.exit_code == 0
163
+ assert "peer-a-codex" in result.output
164
+ assert "peer-b-gemini" in result.output
165
+
166
+
167
+ def test_paircode_peerlab_help_is_reachable():
168
+ runner = CliRunner()
169
+ for argv in (["peerlab", "--help"], ["peerlab", "ensure", "--help"],
170
+ ["peerlab", "invoke", "--help"], ["peerlab", "list", "--help"]):
171
+ result = runner.invoke(main, argv)
172
+ assert result.exit_code == 0, f"{argv}: {result.output}"
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # Seed excludes honored
177
+ # ---------------------------------------------------------------------------
178
+
179
+ def test_seed_excludes_dot_paircode_and_dot_git(tmp_path, monkeypatch):
180
+ """Seeded labs must not contain `.paircode/` (recursion) or the outer
181
+ repo's `.git/` (each lab gets its own fresh git instead)."""
182
+ # Project has a real outer .git with real history
183
+ subprocess.run(["git", "-C", str(tmp_path), "init", "--quiet"], check=False)
184
+ subprocess.run(
185
+ ["git", "-C", str(tmp_path), "-c", "user.name=t", "-c", "user.email=t@t",
186
+ "commit", "--quiet", "--allow-empty", "-m", "outer initial"],
187
+ check=False,
188
+ )
189
+ # Populate via the shared helper (creates src/app.py + README.md)
190
+ _init_and_scaffold(tmp_path, monkeypatch)
191
+ state = find_paircode()
192
+ ensure_peer_labs(state)
193
+ lab = peer_lab_path(state, "peer-a-codex")
194
+ # Lab must not have .paircode (recursion guard)
195
+ assert not (lab / ".paircode").exists(), "lab should not contain .paircode/"
196
+ # Lab's .git exists — its OWN
197
+ assert (lab / ".git").exists()
198
+ # Critically, the outer repo's .git log must NOT have leaked into the lab's
199
+ # git log. They're independent histories.
200
+ outer_log = subprocess.run(
201
+ ["git", "-C", str(tmp_path), "log", "--oneline"],
202
+ capture_output=True, text=True, check=False,
203
+ ).stdout.strip()
204
+ lab_log = subprocess.run(
205
+ ["git", "-C", str(lab), "log", "--oneline"],
206
+ capture_output=True, text=True, check=False,
207
+ ).stdout.strip()
208
+ assert "outer initial" in outer_log
209
+ assert "outer initial" not in lab_log, "outer git history leaked into lab!"
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Installer — /peerlab.md deployed alongside /paircode.md
214
+ # ---------------------------------------------------------------------------
215
+
216
+ def test_installer_deploys_peerlab_slash_command(tmp_path, monkeypatch):
217
+ fake_home = tmp_path / "home"
218
+ fake_home.mkdir()
219
+ monkeypatch.setenv("HOME", str(fake_home))
220
+ import importlib
221
+
222
+ import paircode.detect as d
223
+ import paircode.installer as inst
224
+ importlib.reload(d)
225
+ importlib.reload(inst)
226
+
227
+ monkeypatch.setattr("shutil.which", lambda b: f"/fake/{b}")
228
+
229
+ from cliworker.core import CLIResult
230
+ from cliworker.registry import CLISpec
231
+
232
+ def fake_invoke(cli, *args, **kwargs):
233
+ return CLIResult(
234
+ spec=CLISpec(cli=cli), ok=True, stdout="", stderr="",
235
+ duration_s=0.01, returncode=0, argv=[cli, *args], skipped_reason=None,
236
+ )
237
+ monkeypatch.setattr("paircode.installer.invoke", fake_invoke)
238
+
239
+ inst.install_all()
240
+ peerlab_cmd = fake_home / ".claude" / "commands" / "peerlab.md"
241
+ assert peerlab_cmd.exists(), "install_claude must deploy /peerlab.md"
242
+ assert "peerlab" in peerlab_cmd.read_text()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes