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.
- {paircode-0.11.7 → paircode-0.12.3}/PKG-INFO +1 -1
- {paircode-0.11.7 → paircode-0.12.3}/pyproject.toml +1 -1
- {paircode-0.11.7 → paircode-0.12.3}/src/paircode/__init__.py +1 -1
- {paircode-0.11.7 → paircode-0.12.3}/src/paircode/cli.py +146 -0
- {paircode-0.11.7 → paircode-0.12.3}/src/paircode/installer.py +10 -4
- paircode-0.12.3/src/paircode/peerlab.py +248 -0
- paircode-0.12.3/src/paircode/templates/claude/commands/peerlab.md +71 -0
- paircode-0.12.3/tests/test_peerlab.py +319 -0
- {paircode-0.11.7 → paircode-0.12.3}/.gitignore +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/LICENSE +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/README.md +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/diary/001-step-a-architecture.md +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/diary/002-v0.10-release-pipeline.md +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/diary/003-arch-b-pivot-grappling.md +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/src/paircode/__main__.py +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/src/paircode/converge.py +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/src/paircode/detect.py +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/src/paircode/handshake.py +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/src/paircode/runner.py +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/src/paircode/state.py +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/src/paircode/templates/FOCUS.md +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/src/paircode/templates/JOURNEY.md +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/src/paircode/templates/claude/commands/paircode.md +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/src/paircode/templates/codex/commands/paircode.md +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/src/paircode/templates/gemini/commands/paircode.toml +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/src/paircode/templates/peers.yaml +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/src/paircode/util.py +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/tests/__init__.py +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/tests/test_cli_smoke.py +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/tests/test_converge.py +0 -0
- {paircode-0.11.7 → paircode-0.12.3}/tests/test_smoke.py +0 -0
- {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.
|
|
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.
|
|
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" }
|
|
@@ -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
|
-
|
|
68
|
-
|
|
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=
|
|
74
|
-
message=f"Wrote /paircode slash
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|