paircode 0.11.7__tar.gz → 0.12.3__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.3}/PKG-INFO +1 -1
  2. {paircode-0.11.7 → paircode-0.12.3}/pyproject.toml +1 -1
  3. {paircode-0.11.7 → paircode-0.12.3}/src/paircode/__init__.py +1 -1
  4. {paircode-0.11.7 → paircode-0.12.3}/src/paircode/cli.py +146 -0
  5. {paircode-0.11.7 → paircode-0.12.3}/src/paircode/installer.py +10 -4
  6. paircode-0.12.3/src/paircode/peerlab.py +248 -0
  7. paircode-0.12.3/src/paircode/templates/claude/commands/peerlab.md +71 -0
  8. paircode-0.12.3/tests/test_peerlab.py +319 -0
  9. {paircode-0.11.7 → paircode-0.12.3}/.gitignore +0 -0
  10. {paircode-0.11.7 → paircode-0.12.3}/LICENSE +0 -0
  11. {paircode-0.11.7 → paircode-0.12.3}/README.md +0 -0
  12. {paircode-0.11.7 → paircode-0.12.3}/diary/001-step-a-architecture.md +0 -0
  13. {paircode-0.11.7 → paircode-0.12.3}/diary/002-v0.10-release-pipeline.md +0 -0
  14. {paircode-0.11.7 → paircode-0.12.3}/diary/003-arch-b-pivot-grappling.md +0 -0
  15. {paircode-0.11.7 → paircode-0.12.3}/src/paircode/__main__.py +0 -0
  16. {paircode-0.11.7 → paircode-0.12.3}/src/paircode/converge.py +0 -0
  17. {paircode-0.11.7 → paircode-0.12.3}/src/paircode/detect.py +0 -0
  18. {paircode-0.11.7 → paircode-0.12.3}/src/paircode/handshake.py +0 -0
  19. {paircode-0.11.7 → paircode-0.12.3}/src/paircode/runner.py +0 -0
  20. {paircode-0.11.7 → paircode-0.12.3}/src/paircode/state.py +0 -0
  21. {paircode-0.11.7 → paircode-0.12.3}/src/paircode/templates/FOCUS.md +0 -0
  22. {paircode-0.11.7 → paircode-0.12.3}/src/paircode/templates/JOURNEY.md +0 -0
  23. {paircode-0.11.7 → paircode-0.12.3}/src/paircode/templates/claude/commands/paircode.md +0 -0
  24. {paircode-0.11.7 → paircode-0.12.3}/src/paircode/templates/codex/commands/paircode.md +0 -0
  25. {paircode-0.11.7 → paircode-0.12.3}/src/paircode/templates/gemini/commands/paircode.toml +0 -0
  26. {paircode-0.11.7 → paircode-0.12.3}/src/paircode/templates/peers.yaml +0 -0
  27. {paircode-0.11.7 → paircode-0.12.3}/src/paircode/util.py +0 -0
  28. {paircode-0.11.7 → paircode-0.12.3}/tests/__init__.py +0 -0
  29. {paircode-0.11.7 → paircode-0.12.3}/tests/test_cli_smoke.py +0 -0
  30. {paircode-0.11.7 → paircode-0.12.3}/tests/test_converge.py +0 -0
  31. {paircode-0.11.7 → paircode-0.12.3}/tests/test_smoke.py +0 -0
  32. {paircode-0.11.7 → paircode-0.12.3}/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.3
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.3"
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.2"
@@ -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,248 @@
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
+ # Defaults dropped into each new peer lab's own .gitignore so peer commits
45
+ # don't accidentally include Python bytecode, editor junk, OS cruft.
46
+ # If the lab inherited a .gitignore from the project root (rsync seed), we
47
+ # APPEND this block — user's rules win, these are a safety net.
48
+ LAB_GITIGNORE_BLOCK = """\
49
+ # peerlab defaults — python + editor junk (keep peer commits clean)
50
+ __pycache__/
51
+ *.py[cod]
52
+ *$py.class
53
+ *.egg-info/
54
+ .pytest_cache/
55
+ .mypy_cache/
56
+ .ruff_cache/
57
+ .tox/
58
+ .coverage
59
+ .coverage.*
60
+ htmlcov/
61
+ .DS_Store
62
+ .idea/
63
+ .vscode/
64
+ *.swp
65
+ """
66
+ LAB_GITIGNORE_MARKER = "peerlab defaults"
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class EnsureResult:
71
+ peer_id: str
72
+ lab_path: Path
73
+ status: str # "created" | "already-exists" | "missing-id"
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Helpers — gitignore, rsync, git init, initial commit
78
+ # ---------------------------------------------------------------------------
79
+
80
+ def ensure_gitignore(project_root: Path) -> bool:
81
+ """Append `.peerlab/` to outer .gitignore if not already present.
82
+
83
+ Returns True if the line was added (or the file was created), False if
84
+ it was already there.
85
+ """
86
+ gitignore = project_root / ".gitignore"
87
+ existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
88
+ for line in existing.splitlines():
89
+ s = line.strip()
90
+ if s in (".peerlab/", ".peerlab"):
91
+ return False
92
+ tail = existing.rstrip()
93
+ new_text = (tail + "\n\n" if tail else "") + GITIGNORE_BLOCK_HEADER + "\n.peerlab/\n"
94
+ gitignore.write_text(new_text, encoding="utf-8")
95
+ return True
96
+
97
+
98
+ def _rsync_project(src: Path, dst: Path, excludes: Iterable[str] = SEED_EXCLUDES) -> None:
99
+ """Copy src → dst with standard excludes. Uses rsync if available, falls
100
+ back to `shutil.copytree` with an ignore function."""
101
+ dst.mkdir(parents=True, exist_ok=True)
102
+ rsync = shutil.which("rsync")
103
+ if rsync:
104
+ args = [rsync, "-a"]
105
+ for e in excludes:
106
+ args.extend(["--exclude", e])
107
+ args.extend([str(src) + "/", str(dst) + "/"])
108
+ subprocess.run(args, check=True)
109
+ return
110
+
111
+ # Pure-Python fallback: copytree with an ignore func.
112
+ exclude_set = set(excludes)
113
+
114
+ def _ignore(_directory: str, names: list[str]) -> list[str]:
115
+ ignored = []
116
+ for n in names:
117
+ # Match exact name or a simple *-prefix/suffix wildcard
118
+ if n in exclude_set:
119
+ ignored.append(n)
120
+ continue
121
+ for pat in exclude_set:
122
+ if pat.startswith("*") and n.endswith(pat[1:]):
123
+ ignored.append(n); break
124
+ if pat.endswith("*") and n.startswith(pat[:-1]):
125
+ ignored.append(n); break
126
+ return ignored
127
+
128
+ # copytree fails if dst exists — but our dst was just mkdir'd.
129
+ # Remove empty dst and let copytree re-create.
130
+ if dst.exists() and not any(dst.iterdir()):
131
+ dst.rmdir()
132
+ shutil.copytree(src, dst, ignore=_ignore)
133
+
134
+
135
+ def ensure_lab_gitignore(lab: Path) -> bool:
136
+ """Ensure the peer lab has Python/editor junk gitignored. Idempotent.
137
+
138
+ If the lab already has a `.gitignore` (inherited via rsync from the
139
+ project root), our block is appended so user rules win. If none exists
140
+ (greenfield), our block IS the .gitignore.
141
+
142
+ Returns True if the block was added this call, False if it was already
143
+ there (marker detection).
144
+ """
145
+ gitignore = lab / ".gitignore"
146
+ existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
147
+ if LAB_GITIGNORE_MARKER in existing:
148
+ return False
149
+ new_text = (existing.rstrip() + "\n\n" if existing.strip() else "") + LAB_GITIGNORE_BLOCK
150
+ gitignore.write_text(new_text, encoding="utf-8")
151
+ return True
152
+
153
+
154
+ def _git_init(lab: Path) -> None:
155
+ """`git init` inside `lab/`. No-op if already inited."""
156
+ if (lab / ".git").exists():
157
+ return
158
+ # Try modern flag first, fall back for older git.
159
+ result = subprocess.run(
160
+ ["git", "init", "--quiet", "--initial-branch=main", str(lab)],
161
+ capture_output=True, text=True, check=False,
162
+ )
163
+ if result.returncode != 0 or not (lab / ".git").exists():
164
+ subprocess.run(["git", "init", "--quiet", str(lab)], check=True)
165
+
166
+
167
+ def _git_initial_commit(
168
+ lab: Path, peer_id: str, message: str = "initial seed from project root"
169
+ ) -> bool:
170
+ """Stage everything + commit. Returns True if a commit was made.
171
+
172
+ Authors the commit as the peer itself (`{peer_id} <{peer_id}@peerlab.local>`)
173
+ via lab-local git config. Every subsequent peer commit inside this lab
174
+ inherits that local identity — so `git log` in a peer's lab attributes
175
+ all work to that peer, clean replay.
176
+ """
177
+ if not any(lab.iterdir()):
178
+ return False
179
+ # Local git config — scoped to this lab, doesn't touch global identity.
180
+ subprocess.run(["git", "-C", str(lab), "config", "user.name", peer_id], check=False)
181
+ subprocess.run(
182
+ ["git", "-C", str(lab), "config", "user.email", f"{peer_id}@peerlab.local"],
183
+ check=False,
184
+ )
185
+ subprocess.run(["git", "-C", str(lab), "add", "-A"], check=False)
186
+ # Commit only if there's something staged
187
+ diff_check = subprocess.run(
188
+ ["git", "-C", str(lab), "diff", "--cached", "--quiet"],
189
+ check=False,
190
+ )
191
+ if diff_check.returncode == 0:
192
+ return False # nothing staged
193
+ subprocess.run(
194
+ ["git", "-C", str(lab), "commit", "--quiet", "-m", message],
195
+ check=False,
196
+ )
197
+ return True
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Public — ensure per-peer labs
202
+ # ---------------------------------------------------------------------------
203
+
204
+ def ensure_peer_labs(state: PaircodeState) -> list[EnsureResult]:
205
+ """Scaffold `.peerlab/<peer-id>/` for every peer in `peers.yaml`.
206
+
207
+ Idempotent. On first creation of a lab:
208
+ - mkdir `.peerlab/<peer-id>/`
209
+ - rsync project root → lab (minus SEED_EXCLUDES)
210
+ - `git init` inside lab (own repo)
211
+ - initial commit so HEAD exists → peers can `git diff HEAD~1` immediately
212
+
213
+ On subsequent calls: skip labs that already have `.git/` — never re-seed.
214
+ Also ensures the outer `.gitignore` contains `.peerlab/` so the outer repo
215
+ doesn't accidentally track peer labs.
216
+ """
217
+ project_root = state.project_root
218
+ peerlab_root = project_root / PEERLAB_DIRNAME
219
+ peerlab_root.mkdir(exist_ok=True)
220
+
221
+ ensure_gitignore(project_root)
222
+
223
+ results: list[EnsureResult] = []
224
+ for p in read_peers(state):
225
+ pid = p.get("id") if isinstance(p, dict) else getattr(p, "id", None)
226
+ if not pid:
227
+ results.append(EnsureResult(peer_id="?", lab_path=peerlab_root, status="missing-id"))
228
+ continue
229
+ lab = peerlab_root / pid
230
+ if (lab / ".git").exists():
231
+ results.append(EnsureResult(peer_id=pid, lab_path=lab, status="already-exists"))
232
+ continue
233
+ lab.mkdir(exist_ok=True)
234
+ # Seed from project root (might be empty for greenfield — rsync handles fine)
235
+ _rsync_project(project_root, lab)
236
+ # Drop peerlab-default .gitignore so peer commits don't pull in
237
+ # __pycache__, .pytest_cache, editor junk etc. Appends if the lab
238
+ # already inherited a .gitignore from the project root.
239
+ ensure_lab_gitignore(lab)
240
+ _git_init(lab)
241
+ _git_initial_commit(lab, pid, message="initial seed from project root")
242
+ results.append(EnsureResult(peer_id=pid, lab_path=lab, status="created"))
243
+ return results
244
+
245
+
246
+ def peer_lab_path(state: PaircodeState, peer_id: str) -> Path:
247
+ """Return the absolute lab path for a peer id. Does not create."""
248
+ 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,319 @@
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
+ LAB_GITIGNORE_MARKER,
15
+ PEERLAB_DIRNAME,
16
+ ensure_gitignore,
17
+ ensure_lab_gitignore,
18
+ ensure_peer_labs,
19
+ peer_lab_path,
20
+ )
21
+ from paircode.state import find_paircode, init_paircode
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # ensure_peer_labs — scaffold + seed + git init + initial commit
26
+ # ---------------------------------------------------------------------------
27
+
28
+ def _seed_roster_with(tmp_path: Path, monkeypatch) -> None:
29
+ """Make propose_roster return codex + gemini so ensure-scaffold populates
30
+ peers.yaml with both, creating their sandbox dirs too."""
31
+ peers = [
32
+ ProposedPeer(id="peer-a-codex", cli="codex", priority="high", notes=""),
33
+ ProposedPeer(id="peer-b-gemini", cli="gemini", priority="low", notes=""),
34
+ ]
35
+ monkeypatch.setattr("paircode.cli.propose_roster", lambda: peers)
36
+
37
+
38
+ def _init_and_scaffold(tmp_path: Path, monkeypatch) -> None:
39
+ """Seed a .paircode/ + populated project root in tmp_path."""
40
+ # Simulate a small existing project at tmp_path
41
+ (tmp_path / "src").mkdir()
42
+ (tmp_path / "src" / "app.py").write_text("print('hi')\n")
43
+ (tmp_path / "README.md").write_text("# demo\n")
44
+ monkeypatch.chdir(tmp_path)
45
+ _seed_roster_with(tmp_path, monkeypatch)
46
+ runner = CliRunner()
47
+ runner.invoke(main, ["ensure-scaffold"])
48
+
49
+
50
+ def test_ensure_peer_labs_creates_dirs_and_seeds_and_inits_git(tmp_path, monkeypatch):
51
+ """First run: each peer gets a lab, seeded from project root, with own
52
+ .git/ and an initial commit."""
53
+ _init_and_scaffold(tmp_path, monkeypatch)
54
+ state = find_paircode()
55
+ results = ensure_peer_labs(state)
56
+ assert len(results) == 2
57
+ statuses = {r.peer_id: r.status for r in results}
58
+ assert statuses["peer-a-codex"] == "created"
59
+ assert statuses["peer-b-gemini"] == "created"
60
+
61
+ peerlab_root = tmp_path / PEERLAB_DIRNAME
62
+ for pid in ("peer-a-codex", "peer-b-gemini"):
63
+ lab = peerlab_root / pid
64
+ assert lab.is_dir(), f"{lab} missing"
65
+ # Seeded contents
66
+ assert (lab / "src" / "app.py").is_file()
67
+ assert (lab / "README.md").is_file()
68
+ # Own git
69
+ assert (lab / ".git").is_dir()
70
+ # Initial commit exists — HEAD resolves
71
+ r = subprocess.run(
72
+ ["git", "-C", str(lab), "log", "-1", "--oneline"],
73
+ capture_output=True, text=True, check=False,
74
+ )
75
+ assert r.returncode == 0
76
+ assert r.stdout.strip(), f"no initial commit in {lab}"
77
+
78
+
79
+ def test_ensure_peer_labs_is_idempotent(tmp_path, monkeypatch):
80
+ """Second call: labs already exist → status 'already-exists', no re-seed."""
81
+ _init_and_scaffold(tmp_path, monkeypatch)
82
+ state = find_paircode()
83
+ ensure_peer_labs(state)
84
+
85
+ # Simulate the peer evolving: write a new file, commit
86
+ lab = peer_lab_path(state, "peer-a-codex")
87
+ (lab / "new-work.py").write_text("# codex added this\n")
88
+ subprocess.run(["git", "-C", str(lab), "add", "-A"], check=False)
89
+ subprocess.run(
90
+ ["git", "-C", str(lab), "-c", "user.name=t", "-c", "user.email=t@t",
91
+ "commit", "--quiet", "-m", "codex evolution"],
92
+ check=False,
93
+ )
94
+
95
+ # Re-run ensure — should NOT wipe codex's evolution
96
+ results = ensure_peer_labs(state)
97
+ statuses = {r.peer_id: r.status for r in results}
98
+ assert statuses["peer-a-codex"] == "already-exists"
99
+ assert statuses["peer-b-gemini"] == "already-exists"
100
+ assert (lab / "new-work.py").exists(), "idempotent run wiped codex's work!"
101
+
102
+
103
+ def test_ensure_gitignore_adds_peerlab_line(tmp_path):
104
+ """First call appends `.peerlab/` to .gitignore; second call no-ops."""
105
+ gitignore = tmp_path / ".gitignore"
106
+ gitignore.write_text("__pycache__/\n.venv/\n")
107
+ assert ensure_gitignore(tmp_path) is True
108
+ text = gitignore.read_text()
109
+ assert ".peerlab/" in text
110
+ assert "peerlab — per-peer" in text
111
+
112
+ # Second call: already there → no change
113
+ before = text
114
+ assert ensure_gitignore(tmp_path) is False
115
+ assert gitignore.read_text() == before
116
+
117
+
118
+ def test_ensure_gitignore_creates_file_if_missing(tmp_path):
119
+ """No .gitignore → create it with the peerlab entry."""
120
+ gitignore = tmp_path / ".gitignore"
121
+ assert not gitignore.exists()
122
+ assert ensure_gitignore(tmp_path) is True
123
+ assert ".peerlab/" in gitignore.read_text()
124
+
125
+
126
+ def test_ensure_peer_labs_greenfield_minimal_seed(tmp_path, monkeypatch):
127
+ """On a near-empty project, labs still get created with git. The only
128
+ non-git content is the `.gitignore` that ensure_peer_labs itself added
129
+ to the outer repo (then rsync copies it into each lab)."""
130
+ monkeypatch.chdir(tmp_path)
131
+ _seed_roster_with(tmp_path, monkeypatch)
132
+ runner = CliRunner()
133
+ runner.invoke(main, ["ensure-scaffold"])
134
+ state = find_paircode()
135
+ results = ensure_peer_labs(state)
136
+ statuses = {r.peer_id: r.status for r in results}
137
+ assert statuses["peer-a-codex"] == "created"
138
+ lab = peer_lab_path(state, "peer-a-codex")
139
+ assert lab.is_dir()
140
+ assert (lab / ".git").is_dir()
141
+ children = sorted(p.name for p in lab.iterdir() if p.name != ".git")
142
+ # Greenfield: only the inherited .gitignore, nothing else (no source files)
143
+ assert children == [".gitignore"], f"expected just .gitignore, got {children}"
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # CLI surface — paircode peerlab ensure / list
148
+ # ---------------------------------------------------------------------------
149
+
150
+ def test_paircode_peerlab_ensure_command(tmp_path, monkeypatch):
151
+ _init_and_scaffold(tmp_path, monkeypatch)
152
+ runner = CliRunner()
153
+ result = runner.invoke(main, ["peerlab", "ensure"])
154
+ assert result.exit_code == 0, result.output
155
+ assert (tmp_path / ".peerlab" / "peer-a-codex").is_dir()
156
+ assert (tmp_path / ".peerlab" / "peer-b-gemini").is_dir()
157
+
158
+
159
+ def test_paircode_peerlab_list_shows_head(tmp_path, monkeypatch):
160
+ _init_and_scaffold(tmp_path, monkeypatch)
161
+ runner = CliRunner()
162
+ runner.invoke(main, ["peerlab", "ensure"])
163
+ result = runner.invoke(main, ["peerlab", "list"])
164
+ assert result.exit_code == 0
165
+ assert "peer-a-codex" in result.output
166
+ assert "peer-b-gemini" in result.output
167
+
168
+
169
+ def test_paircode_peerlab_help_is_reachable():
170
+ runner = CliRunner()
171
+ for argv in (["peerlab", "--help"], ["peerlab", "ensure", "--help"],
172
+ ["peerlab", "invoke", "--help"], ["peerlab", "list", "--help"]):
173
+ result = runner.invoke(main, argv)
174
+ assert result.exit_code == 0, f"{argv}: {result.output}"
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Seed excludes honored
179
+ # ---------------------------------------------------------------------------
180
+
181
+ def test_lab_gitignore_has_pycache_on_fresh_lab(tmp_path, monkeypatch):
182
+ """Fresh lab's .gitignore excludes __pycache__ and bytecode so peer
183
+ commits don't pull in build junk. Regression for v0.12.0 bug where
184
+ `git add -A` in peer labs committed .pyc files."""
185
+ _init_and_scaffold(tmp_path, monkeypatch)
186
+ state = find_paircode()
187
+ ensure_peer_labs(state)
188
+ lab = peer_lab_path(state, "peer-a-codex")
189
+ gitignore = lab / ".gitignore"
190
+ assert gitignore.exists()
191
+ content = gitignore.read_text()
192
+ assert "__pycache__/" in content
193
+ assert "*.py[cod]" in content
194
+ assert LAB_GITIGNORE_MARKER in content
195
+
196
+
197
+ def test_ensure_lab_gitignore_appends_to_existing(tmp_path):
198
+ """If user's project already has a .gitignore (rsync brought it over),
199
+ the peerlab block is APPENDED — user rules preserved."""
200
+ lab = tmp_path
201
+ (lab / ".gitignore").write_text("# user project rules\nsecrets.env\n")
202
+ assert ensure_lab_gitignore(lab) is True
203
+ content = (lab / ".gitignore").read_text()
204
+ assert "secrets.env" in content # user rule preserved
205
+ assert LAB_GITIGNORE_MARKER in content # our block added
206
+
207
+
208
+ def test_ensure_lab_gitignore_is_idempotent(tmp_path):
209
+ """Second call no-ops if marker already there."""
210
+ lab = tmp_path
211
+ assert ensure_lab_gitignore(lab) is True
212
+ before = (lab / ".gitignore").read_text()
213
+ assert ensure_lab_gitignore(lab) is False
214
+ assert (lab / ".gitignore").read_text() == before
215
+
216
+
217
+ def test_initial_commit_authored_as_peer_id(tmp_path, monkeypatch):
218
+ """Initial seed commit must be authored as the peer_id so replay
219
+ (git log) attributes work to each peer. Fixes v0.12.0 where commits
220
+ were authored as generic 'paircode-peerlab'."""
221
+ _init_and_scaffold(tmp_path, monkeypatch)
222
+ state = find_paircode()
223
+ ensure_peer_labs(state)
224
+ for pid in ("peer-a-codex", "peer-b-gemini"):
225
+ lab = peer_lab_path(state, pid)
226
+ r = subprocess.run(
227
+ ["git", "-C", str(lab), "log", "-1", "--format=%an <%ae>"],
228
+ capture_output=True, text=True, check=False,
229
+ )
230
+ assert pid in r.stdout, f"commit author should contain {pid}; got {r.stdout}"
231
+ assert f"{pid}@peerlab.local" in r.stdout
232
+
233
+
234
+ def test_different_peers_produce_different_initial_commits(tmp_path, monkeypatch):
235
+ """Distinct peer_ids as commit authors mean the seed commit SHAs differ
236
+ across labs — proves independence beyond 'same content, same author,
237
+ accidental SHA collision'."""
238
+ _init_and_scaffold(tmp_path, monkeypatch)
239
+ state = find_paircode()
240
+ ensure_peer_labs(state)
241
+ codex_lab = peer_lab_path(state, "peer-a-codex")
242
+ gemini_lab = peer_lab_path(state, "peer-b-gemini")
243
+
244
+ def head_sha(lab):
245
+ r = subprocess.run(
246
+ ["git", "-C", str(lab), "rev-parse", "HEAD"],
247
+ capture_output=True, text=True, check=False,
248
+ )
249
+ return r.stdout.strip()
250
+
251
+ assert head_sha(codex_lab) != head_sha(gemini_lab), (
252
+ "initial seed commits should differ across labs since authors differ"
253
+ )
254
+
255
+
256
+ def test_seed_excludes_dot_paircode_and_dot_git(tmp_path, monkeypatch):
257
+ """Seeded labs must not contain `.paircode/` (recursion) or the outer
258
+ repo's `.git/` (each lab gets its own fresh git instead)."""
259
+ # Project has a real outer .git with real history
260
+ subprocess.run(["git", "-C", str(tmp_path), "init", "--quiet"], check=False)
261
+ subprocess.run(
262
+ ["git", "-C", str(tmp_path), "-c", "user.name=t", "-c", "user.email=t@t",
263
+ "commit", "--quiet", "--allow-empty", "-m", "outer initial"],
264
+ check=False,
265
+ )
266
+ # Populate via the shared helper (creates src/app.py + README.md)
267
+ _init_and_scaffold(tmp_path, monkeypatch)
268
+ state = find_paircode()
269
+ ensure_peer_labs(state)
270
+ lab = peer_lab_path(state, "peer-a-codex")
271
+ # Lab must not have .paircode (recursion guard)
272
+ assert not (lab / ".paircode").exists(), "lab should not contain .paircode/"
273
+ # Lab's .git exists — its OWN
274
+ assert (lab / ".git").exists()
275
+ # Critically, the outer repo's .git log must NOT have leaked into the lab's
276
+ # git log. They're independent histories.
277
+ outer_log = subprocess.run(
278
+ ["git", "-C", str(tmp_path), "log", "--oneline"],
279
+ capture_output=True, text=True, check=False,
280
+ ).stdout.strip()
281
+ lab_log = subprocess.run(
282
+ ["git", "-C", str(lab), "log", "--oneline"],
283
+ capture_output=True, text=True, check=False,
284
+ ).stdout.strip()
285
+ assert "outer initial" in outer_log
286
+ assert "outer initial" not in lab_log, "outer git history leaked into lab!"
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # Installer — /peerlab.md deployed alongside /paircode.md
291
+ # ---------------------------------------------------------------------------
292
+
293
+ def test_installer_deploys_peerlab_slash_command(tmp_path, monkeypatch):
294
+ fake_home = tmp_path / "home"
295
+ fake_home.mkdir()
296
+ monkeypatch.setenv("HOME", str(fake_home))
297
+ import importlib
298
+
299
+ import paircode.detect as d
300
+ import paircode.installer as inst
301
+ importlib.reload(d)
302
+ importlib.reload(inst)
303
+
304
+ monkeypatch.setattr("shutil.which", lambda b: f"/fake/{b}")
305
+
306
+ from cliworker.core import CLIResult
307
+ from cliworker.registry import CLISpec
308
+
309
+ def fake_invoke(cli, *args, **kwargs):
310
+ return CLIResult(
311
+ spec=CLISpec(cli=cli), ok=True, stdout="", stderr="",
312
+ duration_s=0.01, returncode=0, argv=[cli, *args], skipped_reason=None,
313
+ )
314
+ monkeypatch.setattr("paircode.installer.invoke", fake_invoke)
315
+
316
+ inst.install_all()
317
+ peerlab_cmd = fake_home / ".claude" / "commands" / "peerlab.md"
318
+ assert peerlab_cmd.exists(), "install_claude must deploy /peerlab.md"
319
+ 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