paircode 0.12.4__tar.gz → 0.13.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.
- {paircode-0.12.4 → paircode-0.13.1}/PKG-INFO +1 -1
- {paircode-0.12.4 → paircode-0.13.1}/pyproject.toml +1 -1
- {paircode-0.12.4 → paircode-0.13.1}/src/paircode/__init__.py +1 -1
- {paircode-0.12.4 → paircode-0.13.1}/src/paircode/cli.py +74 -19
- {paircode-0.12.4 → paircode-0.13.1}/src/paircode/peerlab.py +109 -69
- paircode-0.13.1/src/paircode/templates/claude/commands/peerlab.md +119 -0
- {paircode-0.12.4 → paircode-0.13.1}/tests/test_peerlab.py +198 -174
- paircode-0.12.4/src/paircode/templates/claude/commands/peerlab.md +0 -113
- {paircode-0.12.4 → paircode-0.13.1}/.gitignore +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/LICENSE +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/README.md +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/diary/001-step-a-architecture.md +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/diary/002-v0.10-release-pipeline.md +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/diary/003-arch-b-pivot-grappling.md +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/src/paircode/__main__.py +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/src/paircode/converge.py +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/src/paircode/detect.py +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/src/paircode/handshake.py +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/src/paircode/installer.py +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/src/paircode/runner.py +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/src/paircode/state.py +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/src/paircode/templates/FOCUS.md +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/src/paircode/templates/JOURNEY.md +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/src/paircode/templates/claude/commands/paircode.md +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/src/paircode/templates/codex/commands/paircode.md +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/src/paircode/templates/gemini/commands/paircode.toml +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/src/paircode/templates/peers.yaml +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/src/paircode/util.py +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/tests/__init__.py +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/tests/test_cli_smoke.py +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/tests/test_converge.py +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/tests/test_smoke.py +0 -0
- {paircode-0.12.4 → paircode-0.13.1}/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.13.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.
|
|
7
|
+
version = "0.13.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" }
|
|
@@ -324,12 +324,15 @@ def peerlab() -> None:
|
|
|
324
324
|
@peerlab.command("ensure")
|
|
325
325
|
def peerlab_ensure() -> None:
|
|
326
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
|
|
327
|
+
creation; git init per lab; add .peerlab/ to outer .gitignore. Idempotent.
|
|
329
328
|
|
|
330
|
-
|
|
329
|
+
Uses .peerlab/peers.yaml (peerlab's own roster), not .paircode/.
|
|
330
|
+
"""
|
|
331
|
+
from paircode.peerlab import ensure_peer_labs, find_peerlab, init_peerlab
|
|
332
|
+
|
|
333
|
+
state = find_peerlab()
|
|
331
334
|
if state is None:
|
|
332
|
-
state =
|
|
335
|
+
state = init_peerlab()
|
|
333
336
|
results = ensure_peer_labs(state)
|
|
334
337
|
for r in results:
|
|
335
338
|
if r.status == "created":
|
|
@@ -350,14 +353,14 @@ def peerlab_ensure() -> None:
|
|
|
350
353
|
help="Apply cliworker speed flags")
|
|
351
354
|
def peerlab_invoke(peer_id: str, prompt: str, out_path: str | None, timeout: int, fast: bool) -> None:
|
|
352
355
|
"""Fire a peer CLI with cwd set to its lab. Peer works in-place."""
|
|
353
|
-
from paircode.peerlab import peer_lab_path
|
|
356
|
+
from paircode.peerlab import find_peerlab, peer_lab_path, read_peerlab_peers
|
|
354
357
|
|
|
355
|
-
state =
|
|
358
|
+
state = find_peerlab()
|
|
356
359
|
if state is None:
|
|
357
360
|
raise click.ClickException(
|
|
358
|
-
"No .
|
|
361
|
+
"No .peerlab/ found. Run `paircode peerlab ensure` first."
|
|
359
362
|
)
|
|
360
|
-
peers =
|
|
363
|
+
peers = read_peerlab_peers(state)
|
|
361
364
|
peer = next((p for p in peers if p.get("id") == peer_id), None)
|
|
362
365
|
if peer is None:
|
|
363
366
|
known = [p.get("id") for p in peers]
|
|
@@ -368,14 +371,21 @@ def peerlab_invoke(peer_id: str, prompt: str, out_path: str | None, timeout: int
|
|
|
368
371
|
f"Lab dir missing: {lab}. Run `paircode peerlab ensure` first."
|
|
369
372
|
)
|
|
370
373
|
|
|
371
|
-
# Preamble
|
|
374
|
+
# Preamble: peer knows where its lab is + that cross-reads outside the
|
|
375
|
+
# lab are allowed when the task says so (cross-review reads alpha's
|
|
376
|
+
# project root at ../../ and sibling peer labs at ../).
|
|
372
377
|
framed_prompt = (
|
|
373
|
-
f"You are the {peer_id} peer
|
|
374
|
-
f"Your
|
|
375
|
-
f"your work
|
|
376
|
-
f"the diff.
|
|
378
|
+
f"You are the {peer_id} peer in a paircode peerlab run.\n"
|
|
379
|
+
f"Your own lab is at {lab} — that is your cwd. You have your own `.git/`\n"
|
|
380
|
+
f"here; commit your work in this lab before finishing so the team lead\n"
|
|
381
|
+
f"can read the diff. Modify files ONLY inside your own lab.\n"
|
|
377
382
|
f"\n"
|
|
378
|
-
f"
|
|
383
|
+
f"The task below may instruct you to READ outside your lab — e.g.,\n"
|
|
384
|
+
f"alpha's project root at `../../` (alpha's lab) or sibling peer labs\n"
|
|
385
|
+
f"at `../<other-peer-id>/`. Read-only cross-reads are expected during\n"
|
|
386
|
+
f"cross-review rounds. Do not write outside your own lab.\n"
|
|
387
|
+
f"\n"
|
|
388
|
+
f"Task:\n"
|
|
379
389
|
f"{prompt}"
|
|
380
390
|
)
|
|
381
391
|
|
|
@@ -423,15 +433,15 @@ def peerlab_invoke(peer_id: str, prompt: str, out_path: str | None, timeout: int
|
|
|
423
433
|
@peerlab.command("list")
|
|
424
434
|
def peerlab_list() -> None:
|
|
425
435
|
"""Show peer labs with creation status + HEAD commit (if any)."""
|
|
426
|
-
from paircode.peerlab import peer_lab_path
|
|
436
|
+
from paircode.peerlab import find_peerlab, peer_lab_path, read_peerlab_peers
|
|
427
437
|
|
|
428
|
-
state =
|
|
438
|
+
state = find_peerlab()
|
|
429
439
|
if state is None:
|
|
430
|
-
click.echo("No .
|
|
440
|
+
click.echo("No .peerlab/ found. Run `paircode peerlab ensure` first.")
|
|
431
441
|
return
|
|
432
|
-
peers =
|
|
442
|
+
peers = read_peerlab_peers(state)
|
|
433
443
|
if not peers:
|
|
434
|
-
click.echo("No peers in roster.")
|
|
444
|
+
click.echo("No peers in .peerlab/peers.yaml roster.")
|
|
435
445
|
return
|
|
436
446
|
ptable = Table(show_header=True, header_style="bold")
|
|
437
447
|
ptable.add_column("peer")
|
|
@@ -458,6 +468,51 @@ def peerlab_list() -> None:
|
|
|
458
468
|
console.print(ptable)
|
|
459
469
|
|
|
460
470
|
|
|
471
|
+
@peerlab.command("roster")
|
|
472
|
+
@click.option("--alpha", "alpha_cli", default=None,
|
|
473
|
+
help="CLI acting as alpha (excluded from peers unless last resort).")
|
|
474
|
+
@click.option("--peer", "peer_filter", default=None,
|
|
475
|
+
help="Narrow to a single peer id. Silently falls back if missing.")
|
|
476
|
+
@click.option("--peers", "peers_filter", default=None,
|
|
477
|
+
help="Comma-separated peer ids. Silently falls back if none match.")
|
|
478
|
+
def peerlab_roster(alpha_cli: str | None, peer_filter: str | None, peers_filter: str | None) -> None:
|
|
479
|
+
"""Print peerlab peer ids, one per line — best-effort, never errors.
|
|
480
|
+
|
|
481
|
+
Reads .peerlab/peers.yaml (peerlab's own roster, fully independent of
|
|
482
|
+
.paircode/). Same filter semantics as `paircode roster`.
|
|
483
|
+
"""
|
|
484
|
+
from paircode.peerlab import find_peerlab, read_peerlab_peers
|
|
485
|
+
|
|
486
|
+
state = find_peerlab()
|
|
487
|
+
if state is None:
|
|
488
|
+
return
|
|
489
|
+
all_peers = [p for p in read_peerlab_peers(state) if p.get("id")]
|
|
490
|
+
|
|
491
|
+
if peers_filter:
|
|
492
|
+
wanted = [s.strip() for s in peers_filter.split(",") if s.strip()]
|
|
493
|
+
filtered = [p for p in all_peers if p.get("id") in wanted]
|
|
494
|
+
elif peer_filter:
|
|
495
|
+
filtered = [p for p in all_peers if p.get("id") == peer_filter]
|
|
496
|
+
else:
|
|
497
|
+
filtered = list(all_peers)
|
|
498
|
+
|
|
499
|
+
def _exclude_alpha(peers: list[dict]) -> list[dict]:
|
|
500
|
+
if not alpha_cli:
|
|
501
|
+
return peers
|
|
502
|
+
return [p for p in peers if p.get("cli") != alpha_cli]
|
|
503
|
+
|
|
504
|
+
result = _exclude_alpha(filtered)
|
|
505
|
+
if not result and (peer_filter or peers_filter):
|
|
506
|
+
result = _exclude_alpha(all_peers)
|
|
507
|
+
if not result:
|
|
508
|
+
result = list(all_peers)
|
|
509
|
+
|
|
510
|
+
for p in result:
|
|
511
|
+
pid = p.get("id")
|
|
512
|
+
if pid:
|
|
513
|
+
click.echo(pid)
|
|
514
|
+
|
|
515
|
+
|
|
461
516
|
# ---------------------------------------------------------------------------
|
|
462
517
|
# Bare paircode — show state
|
|
463
518
|
# ---------------------------------------------------------------------------
|
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
"""Per-peer independent labs — each peer owns a parallel implementation
|
|
2
2
|
of the project, with its own `.git/` for independent history.
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
**Fully independent from `/paircode`.** `/peerlab` maintains its own state
|
|
5
|
+
under `.peerlab/` at the project root:
|
|
6
|
+
|
|
7
|
+
.peerlab/
|
|
8
|
+
peers.yaml # peerlab's own roster
|
|
9
|
+
alpha-critique.md # alpha's cross-review output (optional, written by slash command)
|
|
10
|
+
peer-a-codex/ # external peer lab (full parallel implementation, own .git)
|
|
11
|
+
peer-b-gemini/
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
`/peerlab` does NOT read or write `.paircode/`. If you use both `/paircode`
|
|
15
|
+
and `/peerlab`, each maintains its own roster independently. That's
|
|
16
|
+
intentional — `/peerlab` is designed to be useful as a standalone concept.
|
|
17
|
+
|
|
18
|
+
Architecture: **alpha (the interactive LLM session, e.g. Claude Code) is
|
|
19
|
+
itself a peer**. Alpha's "lab" is the project root — alpha codes directly
|
|
20
|
+
in the real repo. This module manages the EXTERNAL peer labs (codex,
|
|
21
|
+
gemini, ollama, ...). During cross-review every participant reads every
|
|
22
|
+
other participant's code.
|
|
12
23
|
"""
|
|
13
24
|
from __future__ import annotations
|
|
14
25
|
|
|
@@ -18,10 +29,11 @@ from dataclasses import dataclass
|
|
|
18
29
|
from pathlib import Path
|
|
19
30
|
from typing import Iterable
|
|
20
31
|
|
|
21
|
-
|
|
32
|
+
import yaml
|
|
22
33
|
|
|
23
34
|
|
|
24
35
|
PEERLAB_DIRNAME = ".peerlab"
|
|
36
|
+
PEERLAB_PEERS_FILE = "peers.yaml"
|
|
25
37
|
|
|
26
38
|
SEED_EXCLUDES: tuple[str, ...] = (
|
|
27
39
|
".git",
|
|
@@ -41,10 +53,6 @@ SEED_EXCLUDES: tuple[str, ...] = (
|
|
|
41
53
|
|
|
42
54
|
GITIGNORE_BLOCK_HEADER = "# peerlab — per-peer independent parallel labs"
|
|
43
55
|
|
|
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
56
|
LAB_GITIGNORE_BLOCK = """\
|
|
49
57
|
# peerlab defaults — python + editor junk (keep peer commits clean)
|
|
50
58
|
__pycache__/
|
|
@@ -66,6 +74,21 @@ htmlcov/
|
|
|
66
74
|
LAB_GITIGNORE_MARKER = "peerlab defaults"
|
|
67
75
|
|
|
68
76
|
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# PeerlabState — independent of PaircodeState
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class PeerlabState:
|
|
83
|
+
"""`.peerlab/` state. Fully independent from `.paircode/`."""
|
|
84
|
+
root: Path # path to .peerlab/ dir
|
|
85
|
+
peers_path: Path # .peerlab/peers.yaml
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def project_root(self) -> Path:
|
|
89
|
+
return self.root.parent
|
|
90
|
+
|
|
91
|
+
|
|
69
92
|
@dataclass(frozen=True)
|
|
70
93
|
class EnsureResult:
|
|
71
94
|
peer_id: str
|
|
@@ -73,16 +96,61 @@ class EnsureResult:
|
|
|
73
96
|
status: str # "created" | "already-exists" | "missing-id"
|
|
74
97
|
|
|
75
98
|
|
|
99
|
+
def find_peerlab(start: Path | None = None) -> PeerlabState | None:
|
|
100
|
+
"""Walk up from `start` (or cwd) looking for `.peerlab/`. Return None."""
|
|
101
|
+
if start is None:
|
|
102
|
+
start = Path.cwd()
|
|
103
|
+
start = start.resolve()
|
|
104
|
+
for ancestor in [start, *start.parents]:
|
|
105
|
+
root = ancestor / PEERLAB_DIRNAME
|
|
106
|
+
if root.is_dir():
|
|
107
|
+
return PeerlabState(root=root, peers_path=root / PEERLAB_PEERS_FILE)
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def init_peerlab(project_root: Path | None = None) -> PeerlabState:
|
|
112
|
+
"""Bootstrap `.peerlab/` in `project_root` (or cwd).
|
|
113
|
+
|
|
114
|
+
Creates the dir, runs handshake to detect peer CLIs, writes a fresh
|
|
115
|
+
`peers.yaml` to `.peerlab/peers.yaml` (if missing). `handshake.propose_roster`
|
|
116
|
+
is pure — reads PATH, doesn't touch any paircode state on disk.
|
|
117
|
+
"""
|
|
118
|
+
if project_root is None:
|
|
119
|
+
project_root = Path.cwd()
|
|
120
|
+
project_root = project_root.resolve()
|
|
121
|
+
root = project_root / PEERLAB_DIRNAME
|
|
122
|
+
root.mkdir(exist_ok=True)
|
|
123
|
+
peers_path = root / PEERLAB_PEERS_FILE
|
|
124
|
+
if not peers_path.exists():
|
|
125
|
+
# Pure-function import; doesn't trigger any paircode state writes.
|
|
126
|
+
from paircode.handshake import propose_roster, proposed_as_yaml_dicts
|
|
127
|
+
|
|
128
|
+
proposed = propose_roster()
|
|
129
|
+
write_peerlab_peers(peers_path, proposed_as_yaml_dicts(proposed))
|
|
130
|
+
return PeerlabState(root=root, peers_path=peers_path)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def read_peerlab_peers(state: PeerlabState) -> list[dict]:
|
|
134
|
+
"""Parse `.peerlab/peers.yaml` into list of peer dicts. `[]` if missing."""
|
|
135
|
+
if not state.peers_path.exists():
|
|
136
|
+
return []
|
|
137
|
+
data = yaml.safe_load(state.peers_path.read_text(encoding="utf-8")) or {}
|
|
138
|
+
return list(data.get("peers") or [])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def write_peerlab_peers(peers_path: Path, peers: Iterable[dict]) -> None:
|
|
142
|
+
peers_path.write_text(
|
|
143
|
+
yaml.safe_dump({"peers": list(peers)}, sort_keys=False, default_flow_style=False),
|
|
144
|
+
encoding="utf-8",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
76
148
|
# ---------------------------------------------------------------------------
|
|
77
|
-
#
|
|
149
|
+
# Outer-repo gitignore — keep `.peerlab/` out of alpha's git
|
|
78
150
|
# ---------------------------------------------------------------------------
|
|
79
151
|
|
|
80
152
|
def ensure_gitignore(project_root: Path) -> bool:
|
|
81
|
-
"""Append `.peerlab/` to outer
|
|
82
|
-
|
|
83
|
-
Returns True if the line was added (or the file was created), False if
|
|
84
|
-
it was already there.
|
|
85
|
-
"""
|
|
153
|
+
"""Append `.peerlab/` to outer `.gitignore` if not already present."""
|
|
86
154
|
gitignore = project_root / ".gitignore"
|
|
87
155
|
existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
|
|
88
156
|
for line in existing.splitlines():
|
|
@@ -95,9 +163,12 @@ def ensure_gitignore(project_root: Path) -> bool:
|
|
|
95
163
|
return True
|
|
96
164
|
|
|
97
165
|
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Per-lab helpers — rsync seed, git init, initial commit, lab .gitignore
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
98
170
|
def _rsync_project(src: Path, dst: Path, excludes: Iterable[str] = SEED_EXCLUDES) -> None:
|
|
99
|
-
"""Copy src → dst with standard excludes.
|
|
100
|
-
back to `shutil.copytree` with an ignore function."""
|
|
171
|
+
"""Copy src → dst with standard excludes. rsync if available, else shutil."""
|
|
101
172
|
dst.mkdir(parents=True, exist_ok=True)
|
|
102
173
|
rsync = shutil.which("rsync")
|
|
103
174
|
if rsync:
|
|
@@ -108,13 +179,11 @@ def _rsync_project(src: Path, dst: Path, excludes: Iterable[str] = SEED_EXCLUDES
|
|
|
108
179
|
subprocess.run(args, check=True)
|
|
109
180
|
return
|
|
110
181
|
|
|
111
|
-
# Pure-Python fallback: copytree with an ignore func.
|
|
112
182
|
exclude_set = set(excludes)
|
|
113
183
|
|
|
114
184
|
def _ignore(_directory: str, names: list[str]) -> list[str]:
|
|
115
185
|
ignored = []
|
|
116
186
|
for n in names:
|
|
117
|
-
# Match exact name or a simple *-prefix/suffix wildcard
|
|
118
187
|
if n in exclude_set:
|
|
119
188
|
ignored.append(n)
|
|
120
189
|
continue
|
|
@@ -125,23 +194,13 @@ def _rsync_project(src: Path, dst: Path, excludes: Iterable[str] = SEED_EXCLUDES
|
|
|
125
194
|
ignored.append(n); break
|
|
126
195
|
return ignored
|
|
127
196
|
|
|
128
|
-
# copytree fails if dst exists — but our dst was just mkdir'd.
|
|
129
|
-
# Remove empty dst and let copytree re-create.
|
|
130
197
|
if dst.exists() and not any(dst.iterdir()):
|
|
131
198
|
dst.rmdir()
|
|
132
199
|
shutil.copytree(src, dst, ignore=_ignore)
|
|
133
200
|
|
|
134
201
|
|
|
135
202
|
def ensure_lab_gitignore(lab: Path) -> bool:
|
|
136
|
-
"""
|
|
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
|
-
"""
|
|
203
|
+
"""Append the peerlab-defaults .gitignore block into the lab. Idempotent."""
|
|
145
204
|
gitignore = lab / ".gitignore"
|
|
146
205
|
existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
|
|
147
206
|
if LAB_GITIGNORE_MARKER in existing:
|
|
@@ -152,10 +211,8 @@ def ensure_lab_gitignore(lab: Path) -> bool:
|
|
|
152
211
|
|
|
153
212
|
|
|
154
213
|
def _git_init(lab: Path) -> None:
|
|
155
|
-
"""`git init` inside `lab/`. No-op if already inited."""
|
|
156
214
|
if (lab / ".git").exists():
|
|
157
215
|
return
|
|
158
|
-
# Try modern flag first, fall back for older git.
|
|
159
216
|
result = subprocess.run(
|
|
160
217
|
["git", "init", "--quiet", "--initial-branch=main", str(lab)],
|
|
161
218
|
capture_output=True, text=True, check=False,
|
|
@@ -167,32 +224,22 @@ def _git_init(lab: Path) -> None:
|
|
|
167
224
|
def _git_initial_commit(
|
|
168
225
|
lab: Path, peer_id: str, message: str = "initial seed from project root"
|
|
169
226
|
) -> 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
|
-
"""
|
|
227
|
+
"""Stage everything + commit as peer_id. Returns True if a commit was made."""
|
|
177
228
|
if not any(lab.iterdir()):
|
|
178
229
|
return False
|
|
179
|
-
# Local git config — scoped to this lab, doesn't touch global identity.
|
|
180
230
|
subprocess.run(["git", "-C", str(lab), "config", "user.name", peer_id], check=False)
|
|
181
231
|
subprocess.run(
|
|
182
232
|
["git", "-C", str(lab), "config", "user.email", f"{peer_id}@peerlab.local"],
|
|
183
233
|
check=False,
|
|
184
234
|
)
|
|
185
235
|
subprocess.run(["git", "-C", str(lab), "add", "-A"], check=False)
|
|
186
|
-
# Commit only if there's something staged
|
|
187
236
|
diff_check = subprocess.run(
|
|
188
|
-
["git", "-C", str(lab), "diff", "--cached", "--quiet"],
|
|
189
|
-
check=False,
|
|
237
|
+
["git", "-C", str(lab), "diff", "--cached", "--quiet"], check=False,
|
|
190
238
|
)
|
|
191
239
|
if diff_check.returncode == 0:
|
|
192
|
-
return False
|
|
240
|
+
return False
|
|
193
241
|
subprocess.run(
|
|
194
|
-
["git", "-C", str(lab), "commit", "--quiet", "-m", message],
|
|
195
|
-
check=False,
|
|
242
|
+
["git", "-C", str(lab), "commit", "--quiet", "-m", message], check=False,
|
|
196
243
|
)
|
|
197
244
|
return True
|
|
198
245
|
|
|
@@ -201,41 +248,34 @@ def _git_initial_commit(
|
|
|
201
248
|
# Public — ensure per-peer labs
|
|
202
249
|
# ---------------------------------------------------------------------------
|
|
203
250
|
|
|
204
|
-
def ensure_peer_labs(state:
|
|
205
|
-
"""Scaffold `.peerlab/<peer-id>/` for every peer in
|
|
251
|
+
def ensure_peer_labs(state: PeerlabState) -> list[EnsureResult]:
|
|
252
|
+
"""Scaffold `.peerlab/<peer-id>/` for every peer in `.peerlab/peers.yaml`.
|
|
206
253
|
|
|
207
254
|
Idempotent. On first creation of a lab:
|
|
208
|
-
- mkdir
|
|
255
|
+
- mkdir
|
|
209
256
|
- rsync project root → lab (minus SEED_EXCLUDES)
|
|
210
|
-
-
|
|
211
|
-
-
|
|
257
|
+
- drop lab .gitignore
|
|
258
|
+
- `git init` inside lab
|
|
259
|
+
- initial commit authored as peer_id so HEAD exists + log attributes clean
|
|
212
260
|
|
|
213
261
|
On subsequent calls: skip labs that already have `.git/` — never re-seed.
|
|
214
|
-
Also ensures the outer `.gitignore` contains `.peerlab
|
|
215
|
-
doesn't accidentally track peer labs.
|
|
262
|
+
Also ensures the outer `.gitignore` contains `.peerlab/`.
|
|
216
263
|
"""
|
|
217
264
|
project_root = state.project_root
|
|
218
|
-
peerlab_root = project_root / PEERLAB_DIRNAME
|
|
219
|
-
peerlab_root.mkdir(exist_ok=True)
|
|
220
|
-
|
|
221
265
|
ensure_gitignore(project_root)
|
|
222
266
|
|
|
223
267
|
results: list[EnsureResult] = []
|
|
224
|
-
for p in
|
|
268
|
+
for p in read_peerlab_peers(state):
|
|
225
269
|
pid = p.get("id") if isinstance(p, dict) else getattr(p, "id", None)
|
|
226
270
|
if not pid:
|
|
227
|
-
results.append(EnsureResult(peer_id="?", lab_path=
|
|
271
|
+
results.append(EnsureResult(peer_id="?", lab_path=state.root, status="missing-id"))
|
|
228
272
|
continue
|
|
229
|
-
lab =
|
|
273
|
+
lab = state.root / pid
|
|
230
274
|
if (lab / ".git").exists():
|
|
231
275
|
results.append(EnsureResult(peer_id=pid, lab_path=lab, status="already-exists"))
|
|
232
276
|
continue
|
|
233
277
|
lab.mkdir(exist_ok=True)
|
|
234
|
-
# Seed from project root (might be empty for greenfield — rsync handles fine)
|
|
235
278
|
_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
279
|
ensure_lab_gitignore(lab)
|
|
240
280
|
_git_init(lab)
|
|
241
281
|
_git_initial_commit(lab, pid, message="initial seed from project root")
|
|
@@ -243,6 +283,6 @@ def ensure_peer_labs(state: PaircodeState) -> list[EnsureResult]:
|
|
|
243
283
|
return results
|
|
244
284
|
|
|
245
285
|
|
|
246
|
-
def peer_lab_path(state:
|
|
286
|
+
def peer_lab_path(state: PeerlabState, peer_id: str) -> Path:
|
|
247
287
|
"""Return the absolute lab path for a peer id. Does not create."""
|
|
248
|
-
return state.
|
|
288
|
+
return state.root / peer_id
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: peerlab — alpha and peers each build in their own lab, then cross-review each other's code. Alpha is one of the peers.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
You are the orchestrator of a `/peerlab` run. In `/peerlab`, **alpha (this Claude session) is one of the peers** — alpha builds in the project root while external peers build in their own labs at `.peerlab/<peer-id>/`. Then every participant reads every other participant's code and writes a critique. Finally you (wearing the orchestrator hat) synthesize the full cross-review into a single chat message.
|
|
6
|
+
|
|
7
|
+
This mirrors mlmodel's `/peerkickoff`: claude has `LABS/claude-kiss/`, codex has `LABS/codex-kiss/`, they build in parallel, cross-audit, iterate. Same pattern — alpha's lab is just the project root instead of a subdir.
|
|
8
|
+
|
|
9
|
+
Separate concept from `/paircode` — no focus dirs, no stages, no consensus.md. Real code in real labs + peer critiques on disk + your final synthesis in chat.
|
|
10
|
+
|
|
11
|
+
`$ARGUMENTS` is the user's raw prompt.
|
|
12
|
+
|
|
13
|
+
## Step 1 — Bootstrap (silent via Bash)
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
paircode peerlab ensure # idempotent: scaffold/seed/git-init each external peer lab
|
|
17
|
+
PEERS=$(paircode peerlab roster --alpha claude) # external peers (excludes alpha/claude)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Note: `/peerlab` is fully independent from `/paircode` — it maintains its own roster at `.peerlab/peers.yaml` and never touches `.paircode/`. You can use `/peerlab` on a project that has never seen `/paircode`.
|
|
21
|
+
|
|
22
|
+
## Step 2 — Work round (alpha + peers in parallel)
|
|
23
|
+
|
|
24
|
+
**Two streams happen concurrently.** Do NOT serialize them.
|
|
25
|
+
|
|
26
|
+
**Stream A — you, alpha:** implement `$ARGUMENTS` directly in the project root using your own tools (Read, Write, Edit, Bash, run tests). The project root IS alpha's lab. Leave your changes uncommitted in the working tree — the user reviews alpha's diff and commits manually (or discards). You are a peer building alongside others, not a distant reader.
|
|
27
|
+
|
|
28
|
+
**Stream B — external peers:** for each peer-id in `$PEERS`, spawn one subagent via Agent tool (`subagent_type=general-purpose`, `run_in_background=true`) — ALL in a single message so subagents run concurrently with each other AND with Stream A. Each subagent's prompt:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
Monitor peer {peer-id} on the work round. Run:
|
|
32
|
+
|
|
33
|
+
paircode peerlab invoke {peer-id} "<the user's prompt verbatim>"
|
|
34
|
+
|
|
35
|
+
That fires the peer with cwd = .peerlab/{peer-id}/ (its own lab). The peer
|
|
36
|
+
commits its work in its own .git before finishing.
|
|
37
|
+
|
|
38
|
+
After it returns, gather:
|
|
39
|
+
1. Peer's stdout narrative.
|
|
40
|
+
2. git -C .peerlab/{peer-id} log --oneline -5
|
|
41
|
+
3. git -C .peerlab/{peer-id} diff HEAD~1 HEAD --stat
|
|
42
|
+
|
|
43
|
+
Report back under 400 words: peer-id, ok=yes/no, duration, 1-line summary,
|
|
44
|
+
git stat output.
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Once every Stream B subagent has reported AND you've finished Stream A, proceed to Step 3.
|
|
48
|
+
|
|
49
|
+
## Step 3 — Cross-review round (everyone reads everyone)
|
|
50
|
+
|
|
51
|
+
**This is the adversarial heart of /peerlab.** Every participant — alpha AND every external peer — reads every OTHER participant's code and writes a critique. Do not skip.
|
|
52
|
+
|
|
53
|
+
If fewer than 2 participants made real progress in Step 2 (e.g., only alpha; every peer failed), you can skip Step 3 — note it in the synthesis. Otherwise:
|
|
54
|
+
|
|
55
|
+
**Stream A — you, alpha, critiquing every peer lab:** for each peer-id in `$PEERS`:
|
|
56
|
+
- `git -C .peerlab/<peer-id> log -p HEAD~1..HEAD` — read the full diff
|
|
57
|
+
- `Read` individual files inside the lab if the diff doesn't give enough context
|
|
58
|
+
|
|
59
|
+
Write your consolidated critique of the external peer labs to `.peerlab/alpha-critique.md` (at the `.peerlab/` root, NOT inside any peer's lab). Severity-ranked, file:line citations pointing at each peer's files. One section per external peer. Don't pile on, don't rubber-stamp.
|
|
60
|
+
|
|
61
|
+
**Stream B — each external peer, critiquing alpha + siblings:** for each peer-id in `$PEERS`, spawn a subagent (Agent tool, `run_in_background=true`) — ALL in one message, parallel to Stream A. Each subagent's prompt:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
Monitor peer {peer-id} on the cross-review round. Run:
|
|
65
|
+
|
|
66
|
+
paircode peerlab invoke {peer-id} "You already committed your own work
|
|
67
|
+
in your lab (your cwd). Now cross-review every OTHER participant:
|
|
68
|
+
|
|
69
|
+
(1) Alpha's work is at the project root. From your cwd that's ../../ .
|
|
70
|
+
Read what alpha did — either:
|
|
71
|
+
git -C ../../ diff HEAD (if alpha's changes are uncommitted — usually the case)
|
|
72
|
+
git -C ../../ log --oneline -5 + diff HEAD~1 HEAD (if alpha committed)
|
|
73
|
+
Then Read the specific files alpha touched.
|
|
74
|
+
|
|
75
|
+
(2) Sibling peer labs are at ../<other-peer-id>/ . For each:
|
|
76
|
+
git -C ../<other-peer-id> log -p HEAD~1..HEAD
|
|
77
|
+
|
|
78
|
+
Write CRITIQUES.md in your own cwd (your own lab) with one section per
|
|
79
|
+
participant reviewed. Include alpha. Severity-ranked. file:line citations
|
|
80
|
+
pointing at the OTHER participant's files. Don't pile on, don't rubber-stamp.
|
|
81
|
+
|
|
82
|
+
git add CRITIQUES.md
|
|
83
|
+
git commit -m 'cross-review'"
|
|
84
|
+
|
|
85
|
+
After it returns, verify .peerlab/{peer-id}/CRITIQUES.md exists with a
|
|
86
|
+
non-trivial section for alpha plus each sibling. Report back under 300
|
|
87
|
+
words: peer-id, ok=yes/no, duration, 1-line summary of whose code this
|
|
88
|
+
peer found most/least convincing.
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Wait for Stream A AND every Stream B subagent before Step 4.
|
|
92
|
+
|
|
93
|
+
## Step 4 — Synthesize (one message to user, ≤300 words)
|
|
94
|
+
|
|
95
|
+
Read the full cross-review:
|
|
96
|
+
- `.peerlab/alpha-critique.md` — your own critique of the peer labs
|
|
97
|
+
- Each `.peerlab/<peer-id>/CRITIQUES.md` — each peer's critique of alpha + siblings
|
|
98
|
+
- Alpha's own work in the project root (you remember; you did it in Step 2)
|
|
99
|
+
- Each peer's work diff (already gathered in Step 2)
|
|
100
|
+
|
|
101
|
+
Synthesize with the peers' voices, not just yours:
|
|
102
|
+
|
|
103
|
+
- **Implementation summaries** — alpha's approach in project root, each peer's approach in its lab. 1–2 lines each.
|
|
104
|
+
- **What peers said about alpha** — pull the sharpest findings from each peer's CRITIQUES.md about YOUR work. Quote them, don't paraphrase away the teeth.
|
|
105
|
+
- **What alpha said about peers** — reference your alpha-critique.md findings.
|
|
106
|
+
- **Cross-cutting themes** — where did critiques converge? That's high-signal consensus.
|
|
107
|
+
- **Head-to-head** — which implementation won, informed by the cross-review? Don't play favorites, don't hedge.
|
|
108
|
+
- **Next action** — merge a peer's pattern into alpha? Adjust alpha's implementation based on a peer's critique? Fire a peer to self-heal based on alpha's critique? Recommend; user decides.
|
|
109
|
+
|
|
110
|
+
No focus dirs, no consensus.md file. Just your synthesis as the chat reply.
|
|
111
|
+
|
|
112
|
+
## Guardrails
|
|
113
|
+
|
|
114
|
+
- **Alpha IS a peer.** You build in project root during Step 2 — not sitting out.
|
|
115
|
+
- **Uncommitted changes in project root stay uncommitted.** User reviews alpha's diff + commits manually. Do NOT `git add` or `git commit` in the project root as part of `/peerlab`.
|
|
116
|
+
- **External peers commit inside their own labs** — their `.git/` is isolated, `.peerlab/` is gitignored from the outer repo.
|
|
117
|
+
- **Fire every external peer** unless the user's prompt explicitly names `--peer`/`--peers`. Watchdog caps silent hangs at ~32s.
|
|
118
|
+
- **Do not skip Step 3** unless fewer than 2 participants succeeded in Step 2. Cross-review is the point.
|
|
119
|
+
- **No AI attribution** in any artifact you write (alpha-critique.md, chat synthesis). See the maintainer's CLAUDE.md.
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
"""Tests for paircode.peerlab — per-peer parallel labs with own .git.
|
|
1
|
+
"""Tests for paircode.peerlab — per-peer parallel labs with own .git.
|
|
2
|
+
|
|
3
|
+
peerlab maintains its own state under `.peerlab/`, fully independent of
|
|
4
|
+
`.paircode/`. Tests reflect that: no `paircode ensure-scaffold` in the
|
|
5
|
+
setup path.
|
|
6
|
+
"""
|
|
2
7
|
from __future__ import annotations
|
|
3
8
|
|
|
4
9
|
import shutil
|
|
@@ -6,6 +11,7 @@ import subprocess
|
|
|
6
11
|
from pathlib import Path
|
|
7
12
|
|
|
8
13
|
import pytest
|
|
14
|
+
import yaml
|
|
9
15
|
from click.testing import CliRunner
|
|
10
16
|
|
|
11
17
|
from paircode.cli import main
|
|
@@ -13,76 +19,120 @@ from paircode.handshake import ProposedPeer
|
|
|
13
19
|
from paircode.peerlab import (
|
|
14
20
|
LAB_GITIGNORE_MARKER,
|
|
15
21
|
PEERLAB_DIRNAME,
|
|
22
|
+
PEERLAB_PEERS_FILE,
|
|
16
23
|
ensure_gitignore,
|
|
17
24
|
ensure_lab_gitignore,
|
|
18
25
|
ensure_peer_labs,
|
|
26
|
+
find_peerlab,
|
|
27
|
+
init_peerlab,
|
|
19
28
|
peer_lab_path,
|
|
29
|
+
read_peerlab_peers,
|
|
30
|
+
write_peerlab_peers,
|
|
20
31
|
)
|
|
21
|
-
from paircode.state import find_paircode, init_paircode
|
|
22
32
|
|
|
23
33
|
|
|
24
34
|
# ---------------------------------------------------------------------------
|
|
25
|
-
#
|
|
35
|
+
# Fixtures
|
|
26
36
|
# ---------------------------------------------------------------------------
|
|
27
37
|
|
|
28
|
-
def
|
|
29
|
-
"""
|
|
30
|
-
peers.yaml with both, creating their sandbox dirs too."""
|
|
38
|
+
def _seed_roster(monkeypatch) -> list[ProposedPeer]:
|
|
39
|
+
"""Fake handshake detection so init_peerlab writes a predictable roster."""
|
|
31
40
|
peers = [
|
|
32
41
|
ProposedPeer(id="peer-a-codex", cli="codex", priority="high", notes=""),
|
|
33
42
|
ProposedPeer(id="peer-b-gemini", cli="gemini", priority="low", notes=""),
|
|
34
43
|
]
|
|
35
|
-
monkeypatch.setattr("paircode.
|
|
44
|
+
monkeypatch.setattr("paircode.peerlab.propose_roster", lambda: peers, raising=False)
|
|
45
|
+
# The import inside init_peerlab is lazy; patch at both sides to be safe
|
|
46
|
+
monkeypatch.setattr("paircode.handshake.propose_roster", lambda: peers)
|
|
47
|
+
return peers
|
|
36
48
|
|
|
37
49
|
|
|
38
|
-
def
|
|
39
|
-
"""
|
|
40
|
-
# Simulate a small existing project at tmp_path
|
|
50
|
+
def _setup_populated_project(tmp_path: Path, monkeypatch) -> None:
|
|
51
|
+
"""Simulate a small existing project at tmp_path, cd in, seed handshake."""
|
|
41
52
|
(tmp_path / "src").mkdir()
|
|
42
53
|
(tmp_path / "src" / "app.py").write_text("print('hi')\n")
|
|
43
54
|
(tmp_path / "README.md").write_text("# demo\n")
|
|
44
55
|
monkeypatch.chdir(tmp_path)
|
|
45
|
-
|
|
56
|
+
_seed_roster(monkeypatch)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# peerlab state independence — no .paircode/ touched
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def test_init_peerlab_creates_dir_and_peers_yaml(tmp_path, monkeypatch):
|
|
64
|
+
_setup_populated_project(tmp_path, monkeypatch)
|
|
65
|
+
state = init_peerlab()
|
|
66
|
+
assert state.root == tmp_path / PEERLAB_DIRNAME
|
|
67
|
+
assert state.root.is_dir()
|
|
68
|
+
assert state.peers_path == state.root / PEERLAB_PEERS_FILE
|
|
69
|
+
assert state.peers_path.is_file()
|
|
70
|
+
data = yaml.safe_load(state.peers_path.read_text()) or {}
|
|
71
|
+
ids = [p["id"] for p in data.get("peers") or []]
|
|
72
|
+
assert "peer-a-codex" in ids
|
|
73
|
+
assert "peer-b-gemini" in ids
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_peerlab_ensure_does_not_create_dot_paircode(tmp_path, monkeypatch):
|
|
77
|
+
"""Regression: /peerlab must NOT create .paircode/ as a side effect."""
|
|
78
|
+
_setup_populated_project(tmp_path, monkeypatch)
|
|
46
79
|
runner = CliRunner()
|
|
47
|
-
runner.invoke(main, ["ensure
|
|
80
|
+
result = runner.invoke(main, ["peerlab", "ensure"])
|
|
81
|
+
assert result.exit_code == 0, result.output
|
|
82
|
+
assert (tmp_path / ".peerlab").is_dir()
|
|
83
|
+
assert not (tmp_path / ".paircode").exists(), (
|
|
84
|
+
"peerlab ensure must not touch .paircode/ — they're independent state dirs"
|
|
85
|
+
)
|
|
86
|
+
|
|
48
87
|
|
|
88
|
+
def test_find_peerlab_walks_up(tmp_path, monkeypatch):
|
|
89
|
+
_setup_populated_project(tmp_path, monkeypatch)
|
|
90
|
+
init_peerlab()
|
|
91
|
+
# cd into a subdir; find_peerlab should still find .peerlab/ at root
|
|
92
|
+
sub = tmp_path / "src"
|
|
93
|
+
monkeypatch.chdir(sub)
|
|
94
|
+
state = find_peerlab()
|
|
95
|
+
assert state is not None
|
|
96
|
+
assert state.root == tmp_path / PEERLAB_DIRNAME
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_init_peerlab_is_idempotent(tmp_path, monkeypatch):
|
|
100
|
+
_setup_populated_project(tmp_path, monkeypatch)
|
|
101
|
+
state1 = init_peerlab()
|
|
102
|
+
before = state1.peers_path.read_text()
|
|
103
|
+
state2 = init_peerlab()
|
|
104
|
+
assert state2.peers_path.read_text() == before # no-op second call
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# ensure_peer_labs — seed + git init + peer-authored commit
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
49
110
|
|
|
50
111
|
def test_ensure_peer_labs_creates_dirs_and_seeds_and_inits_git(tmp_path, monkeypatch):
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
_init_and_scaffold(tmp_path, monkeypatch)
|
|
54
|
-
state = find_paircode()
|
|
112
|
+
_setup_populated_project(tmp_path, monkeypatch)
|
|
113
|
+
state = init_peerlab()
|
|
55
114
|
results = ensure_peer_labs(state)
|
|
56
|
-
assert len(results) == 2
|
|
57
115
|
statuses = {r.peer_id: r.status for r in results}
|
|
58
116
|
assert statuses["peer-a-codex"] == "created"
|
|
59
117
|
assert statuses["peer-b-gemini"] == "created"
|
|
60
118
|
|
|
61
|
-
peerlab_root = tmp_path / PEERLAB_DIRNAME
|
|
62
119
|
for pid in ("peer-a-codex", "peer-b-gemini"):
|
|
63
|
-
lab =
|
|
64
|
-
assert lab.is_dir()
|
|
65
|
-
# Seeded contents
|
|
120
|
+
lab = tmp_path / PEERLAB_DIRNAME / pid
|
|
121
|
+
assert lab.is_dir()
|
|
66
122
|
assert (lab / "src" / "app.py").is_file()
|
|
67
123
|
assert (lab / "README.md").is_file()
|
|
68
|
-
# Own git
|
|
69
124
|
assert (lab / ".git").is_dir()
|
|
70
|
-
# Initial commit exists — HEAD resolves
|
|
71
125
|
r = subprocess.run(
|
|
72
126
|
["git", "-C", str(lab), "log", "-1", "--oneline"],
|
|
73
127
|
capture_output=True, text=True, check=False,
|
|
74
128
|
)
|
|
75
|
-
assert r.returncode == 0
|
|
76
129
|
assert r.stdout.strip(), f"no initial commit in {lab}"
|
|
77
130
|
|
|
78
131
|
|
|
79
132
|
def test_ensure_peer_labs_is_idempotent(tmp_path, monkeypatch):
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
state = find_paircode()
|
|
133
|
+
_setup_populated_project(tmp_path, monkeypatch)
|
|
134
|
+
state = init_peerlab()
|
|
83
135
|
ensure_peer_labs(state)
|
|
84
|
-
|
|
85
|
-
# Simulate the peer evolving: write a new file, commit
|
|
86
136
|
lab = peer_lab_path(state, "peer-a-codex")
|
|
87
137
|
(lab / "new-work.py").write_text("# codex added this\n")
|
|
88
138
|
subprocess.run(["git", "-C", str(lab), "add", "-A"], check=False)
|
|
@@ -91,189 +141,105 @@ def test_ensure_peer_labs_is_idempotent(tmp_path, monkeypatch):
|
|
|
91
141
|
"commit", "--quiet", "-m", "codex evolution"],
|
|
92
142
|
check=False,
|
|
93
143
|
)
|
|
94
|
-
|
|
95
|
-
# Re-run ensure — should NOT wipe codex's evolution
|
|
96
144
|
results = ensure_peer_labs(state)
|
|
97
145
|
statuses = {r.peer_id: r.status for r in results}
|
|
98
146
|
assert statuses["peer-a-codex"] == "already-exists"
|
|
99
|
-
assert
|
|
100
|
-
assert (lab / "new-work.py").exists(), "idempotent run wiped codex's work!"
|
|
147
|
+
assert (lab / "new-work.py").exists()
|
|
101
148
|
|
|
102
149
|
|
|
103
|
-
def
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
assert gitignore.read_text() == before
|
|
150
|
+
def test_initial_commit_authored_as_peer_id(tmp_path, monkeypatch):
|
|
151
|
+
_setup_populated_project(tmp_path, monkeypatch)
|
|
152
|
+
state = init_peerlab()
|
|
153
|
+
ensure_peer_labs(state)
|
|
154
|
+
for pid in ("peer-a-codex", "peer-b-gemini"):
|
|
155
|
+
lab = peer_lab_path(state, pid)
|
|
156
|
+
r = subprocess.run(
|
|
157
|
+
["git", "-C", str(lab), "log", "-1", "--format=%an <%ae>"],
|
|
158
|
+
capture_output=True, text=True, check=False,
|
|
159
|
+
)
|
|
160
|
+
assert pid in r.stdout
|
|
161
|
+
assert f"{pid}@peerlab.local" in r.stdout
|
|
116
162
|
|
|
117
163
|
|
|
118
|
-
def
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
164
|
+
def test_different_peers_produce_different_initial_commits(tmp_path, monkeypatch):
|
|
165
|
+
_setup_populated_project(tmp_path, monkeypatch)
|
|
166
|
+
state = init_peerlab()
|
|
167
|
+
ensure_peer_labs(state)
|
|
168
|
+
codex_lab = peer_lab_path(state, "peer-a-codex")
|
|
169
|
+
gemini_lab = peer_lab_path(state, "peer-b-gemini")
|
|
124
170
|
|
|
171
|
+
def head_sha(lab):
|
|
172
|
+
r = subprocess.run(
|
|
173
|
+
["git", "-C", str(lab), "rev-parse", "HEAD"],
|
|
174
|
+
capture_output=True, text=True, check=False,
|
|
175
|
+
)
|
|
176
|
+
return r.stdout.strip()
|
|
125
177
|
|
|
126
|
-
|
|
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}"
|
|
178
|
+
assert head_sha(codex_lab) != head_sha(gemini_lab)
|
|
144
179
|
|
|
145
180
|
|
|
146
181
|
# ---------------------------------------------------------------------------
|
|
147
|
-
#
|
|
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
|
|
182
|
+
# lab .gitignore — python/editor defaults
|
|
179
183
|
# ---------------------------------------------------------------------------
|
|
180
184
|
|
|
181
185
|
def test_lab_gitignore_has_pycache_on_fresh_lab(tmp_path, monkeypatch):
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
`git add -A` in peer labs committed .pyc files."""
|
|
185
|
-
_init_and_scaffold(tmp_path, monkeypatch)
|
|
186
|
-
state = find_paircode()
|
|
186
|
+
_setup_populated_project(tmp_path, monkeypatch)
|
|
187
|
+
state = init_peerlab()
|
|
187
188
|
ensure_peer_labs(state)
|
|
188
189
|
lab = peer_lab_path(state, "peer-a-codex")
|
|
189
|
-
|
|
190
|
-
assert gitignore.exists()
|
|
191
|
-
content = gitignore.read_text()
|
|
190
|
+
content = (lab / ".gitignore").read_text()
|
|
192
191
|
assert "__pycache__/" in content
|
|
193
192
|
assert "*.py[cod]" in content
|
|
194
193
|
assert LAB_GITIGNORE_MARKER in content
|
|
195
194
|
|
|
196
195
|
|
|
197
196
|
def test_ensure_lab_gitignore_appends_to_existing(tmp_path):
|
|
198
|
-
"""
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
assert
|
|
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
|
|
197
|
+
(tmp_path / ".gitignore").write_text("# user project rules\nsecrets.env\n")
|
|
198
|
+
assert ensure_lab_gitignore(tmp_path) is True
|
|
199
|
+
content = (tmp_path / ".gitignore").read_text()
|
|
200
|
+
assert "secrets.env" in content
|
|
201
|
+
assert LAB_GITIGNORE_MARKER in content
|
|
206
202
|
|
|
207
203
|
|
|
208
204
|
def test_ensure_lab_gitignore_is_idempotent(tmp_path):
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
assert ensure_lab_gitignore(
|
|
212
|
-
|
|
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
|
|
205
|
+
assert ensure_lab_gitignore(tmp_path) is True
|
|
206
|
+
before = (tmp_path / ".gitignore").read_text()
|
|
207
|
+
assert ensure_lab_gitignore(tmp_path) is False
|
|
208
|
+
assert (tmp_path / ".gitignore").read_text() == before
|
|
232
209
|
|
|
233
210
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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")
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# Outer .gitignore — appends .peerlab/
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
243
214
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
215
|
+
def test_ensure_gitignore_adds_peerlab_line(tmp_path):
|
|
216
|
+
gitignore = tmp_path / ".gitignore"
|
|
217
|
+
gitignore.write_text("__pycache__/\n.venv/\n")
|
|
218
|
+
assert ensure_gitignore(tmp_path) is True
|
|
219
|
+
text = gitignore.read_text()
|
|
220
|
+
assert ".peerlab/" in text
|
|
221
|
+
assert "peerlab — per-peer" in text
|
|
222
|
+
assert ensure_gitignore(tmp_path) is False
|
|
250
223
|
|
|
251
|
-
assert head_sha(codex_lab) != head_sha(gemini_lab), (
|
|
252
|
-
"initial seed commits should differ across labs since authors differ"
|
|
253
|
-
)
|
|
254
224
|
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
# Seed excludes honored
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
255
228
|
|
|
256
|
-
def
|
|
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
|
|
229
|
+
def test_seed_excludes_dot_peerlab_and_outer_git_history(tmp_path, monkeypatch):
|
|
260
230
|
subprocess.run(["git", "-C", str(tmp_path), "init", "--quiet"], check=False)
|
|
261
231
|
subprocess.run(
|
|
262
232
|
["git", "-C", str(tmp_path), "-c", "user.name=t", "-c", "user.email=t@t",
|
|
263
233
|
"commit", "--quiet", "--allow-empty", "-m", "outer initial"],
|
|
264
234
|
check=False,
|
|
265
235
|
)
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
state = find_paircode()
|
|
236
|
+
_setup_populated_project(tmp_path, monkeypatch)
|
|
237
|
+
state = init_peerlab()
|
|
269
238
|
ensure_peer_labs(state)
|
|
270
239
|
lab = peer_lab_path(state, "peer-a-codex")
|
|
271
|
-
|
|
272
|
-
assert not (lab / ".paircode").exists()
|
|
273
|
-
# Lab's .git exists — its OWN
|
|
240
|
+
assert not (lab / ".peerlab").exists()
|
|
241
|
+
assert not (lab / ".paircode").exists()
|
|
274
242
|
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
243
|
outer_log = subprocess.run(
|
|
278
244
|
["git", "-C", str(tmp_path), "log", "--oneline"],
|
|
279
245
|
capture_output=True, text=True, check=False,
|
|
@@ -283,11 +249,69 @@ def test_seed_excludes_dot_paircode_and_dot_git(tmp_path, monkeypatch):
|
|
|
283
249
|
capture_output=True, text=True, check=False,
|
|
284
250
|
).stdout.strip()
|
|
285
251
|
assert "outer initial" in outer_log
|
|
286
|
-
assert "outer initial" not in lab_log
|
|
252
|
+
assert "outer initial" not in lab_log
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# CLI surface
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
def test_paircode_peerlab_help_is_reachable():
|
|
260
|
+
runner = CliRunner()
|
|
261
|
+
for argv in (["peerlab", "--help"], ["peerlab", "ensure", "--help"],
|
|
262
|
+
["peerlab", "invoke", "--help"], ["peerlab", "list", "--help"],
|
|
263
|
+
["peerlab", "roster", "--help"]):
|
|
264
|
+
result = runner.invoke(main, argv)
|
|
265
|
+
assert result.exit_code == 0, f"{argv}: {result.output}"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_paircode_peerlab_ensure_command(tmp_path, monkeypatch):
|
|
269
|
+
_setup_populated_project(tmp_path, monkeypatch)
|
|
270
|
+
runner = CliRunner()
|
|
271
|
+
result = runner.invoke(main, ["peerlab", "ensure"])
|
|
272
|
+
assert result.exit_code == 0, result.output
|
|
273
|
+
assert (tmp_path / ".peerlab" / "peers.yaml").is_file()
|
|
274
|
+
assert (tmp_path / ".peerlab" / "peer-a-codex").is_dir()
|
|
275
|
+
assert (tmp_path / ".peerlab" / "peer-b-gemini").is_dir()
|
|
276
|
+
# AND .paircode/ is NOT created
|
|
277
|
+
assert not (tmp_path / ".paircode").exists()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_paircode_peerlab_list_shows_head(tmp_path, monkeypatch):
|
|
281
|
+
_setup_populated_project(tmp_path, monkeypatch)
|
|
282
|
+
runner = CliRunner()
|
|
283
|
+
runner.invoke(main, ["peerlab", "ensure"])
|
|
284
|
+
result = runner.invoke(main, ["peerlab", "list"])
|
|
285
|
+
assert result.exit_code == 0
|
|
286
|
+
assert "peer-a-codex" in result.output
|
|
287
|
+
assert "peer-b-gemini" in result.output
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_paircode_peerlab_roster_prints_ids(tmp_path, monkeypatch):
|
|
291
|
+
_setup_populated_project(tmp_path, monkeypatch)
|
|
292
|
+
runner = CliRunner()
|
|
293
|
+
runner.invoke(main, ["peerlab", "ensure"])
|
|
294
|
+
result = runner.invoke(main, ["peerlab", "roster"])
|
|
295
|
+
assert result.exit_code == 0
|
|
296
|
+
lines = result.output.strip().splitlines()
|
|
297
|
+
assert "peer-a-codex" in lines
|
|
298
|
+
assert "peer-b-gemini" in lines
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_paircode_peerlab_roster_alpha_filter(tmp_path, monkeypatch):
|
|
302
|
+
"""roster --alpha codex drops codex-cli peers when others are available."""
|
|
303
|
+
_setup_populated_project(tmp_path, monkeypatch)
|
|
304
|
+
runner = CliRunner()
|
|
305
|
+
runner.invoke(main, ["peerlab", "ensure"])
|
|
306
|
+
result = runner.invoke(main, ["peerlab", "roster", "--alpha", "codex"])
|
|
307
|
+
assert result.exit_code == 0
|
|
308
|
+
lines = result.output.strip().splitlines()
|
|
309
|
+
assert "peer-a-codex" not in lines
|
|
310
|
+
assert "peer-b-gemini" in lines
|
|
287
311
|
|
|
288
312
|
|
|
289
313
|
# ---------------------------------------------------------------------------
|
|
290
|
-
# Installer — /peerlab.md deployed alongside /paircode.md
|
|
314
|
+
# Installer — /peerlab.md still deployed alongside /paircode.md
|
|
291
315
|
# ---------------------------------------------------------------------------
|
|
292
316
|
|
|
293
317
|
def test_installer_deploys_peerlab_slash_command(tmp_path, monkeypatch):
|
|
@@ -315,5 +339,5 @@ def test_installer_deploys_peerlab_slash_command(tmp_path, monkeypatch):
|
|
|
315
339
|
|
|
316
340
|
inst.install_all()
|
|
317
341
|
peerlab_cmd = fake_home / ".claude" / "commands" / "peerlab.md"
|
|
318
|
-
assert peerlab_cmd.exists()
|
|
342
|
+
assert peerlab_cmd.exists()
|
|
319
343
|
assert "peerlab" in peerlab_cmd.read_text()
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: peerlab — each peer owns a parallel lab with its own git. Peers write real code, then cross-review each other's labs, then team lead 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. The flow is:
|
|
6
|
-
|
|
7
|
-
1. **Work round** — every peer builds in its own lab.
|
|
8
|
-
2. **Cross-review round** — every peer reads the OTHER peers' labs and writes an honest critique (`CRITIQUES.md`) in its own lab. Peer-reviews-peer is the adversarial content; do NOT skip this.
|
|
9
|
-
3. **Team-lead synthesis** — alpha reads both the code diffs AND each peer's critiques, delivers the final take.
|
|
10
|
-
|
|
11
|
-
Separate concept from `/paircode` — no focus dirs, no stages, no consensus.md. Real code evolution + peer critiques on disk, synthesis in the chat.
|
|
12
|
-
|
|
13
|
-
`$ARGUMENTS` is the user's raw prompt. Pass it through verbatim.
|
|
14
|
-
|
|
15
|
-
## Step 1 — Bootstrap (silent via Bash)
|
|
16
|
-
|
|
17
|
-
```bash
|
|
18
|
-
paircode peerlab ensure # scaffolds + seeds + git-inits each lab (idempotent)
|
|
19
|
-
PEERS=$(paircode roster --alpha claude)
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
`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.
|
|
23
|
-
|
|
24
|
-
## Step 2 — Work round (fire every peer in parallel)
|
|
25
|
-
|
|
26
|
-
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.
|
|
27
|
-
|
|
28
|
-
Each subagent's prompt:
|
|
29
|
-
|
|
30
|
-
```
|
|
31
|
-
Monitor peer {peer-id} on the work round. Run this shell command and wait:
|
|
32
|
-
|
|
33
|
-
paircode peerlab invoke {peer-id} "<the user's prompt verbatim>"
|
|
34
|
-
|
|
35
|
-
That fires the peer with cwd = .peerlab/{peer-id}/ (its own lab). The peer
|
|
36
|
-
commits its work in its own .git before finishing.
|
|
37
|
-
|
|
38
|
-
After it returns, gather:
|
|
39
|
-
1. The peer's stdout (narrative).
|
|
40
|
-
2. git -C .peerlab/{peer-id} log --oneline -5
|
|
41
|
-
3. git -C .peerlab/{peer-id} diff HEAD~1 HEAD --stat
|
|
42
|
-
|
|
43
|
-
Report back under 400 words: peer-id, ok=yes/no, duration, 1-line summary,
|
|
44
|
-
and the git stat.
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
Wait for every peer to finish the work round before starting Step 3.
|
|
48
|
-
|
|
49
|
-
## Step 3 — Cross-review round (peers read each other's labs)
|
|
50
|
-
|
|
51
|
-
This is the adversarial heart of `/peerlab`. Every peer now reads every OTHER peer's lab and writes an honest critique. Do not skip this — it's what makes `/peerlab` different from "parallel output".
|
|
52
|
-
|
|
53
|
-
If only ONE peer finished Step 2 successfully, skip Step 3 (nobody to cross-review) and note it in the final synthesis. Otherwise:
|
|
54
|
-
|
|
55
|
-
For each peer-id in the set of peers that succeeded in Step 2, spawn one subagent (Agent tool, `run_in_background=true`) — all in one message. Each subagent's prompt:
|
|
56
|
-
|
|
57
|
-
```
|
|
58
|
-
Monitor peer {peer-id} on the cross-review round. Run this shell command
|
|
59
|
-
and wait:
|
|
60
|
-
|
|
61
|
-
paircode peerlab invoke {peer-id} "You already committed your own work
|
|
62
|
-
in your lab — your cwd. Now cross-review every OTHER peer's lab.
|
|
63
|
-
Siblings live at ../<other-peer-id>/ relative to your cwd. List them
|
|
64
|
-
with `ls ..` and exclude your own id ({peer-id}).
|
|
65
|
-
|
|
66
|
-
For each other peer:
|
|
67
|
-
git -C ../<other-peer-id> log --oneline
|
|
68
|
-
git -C ../<other-peer-id> diff HEAD~1 HEAD
|
|
69
|
-
# read the changed files directly if the diff isn't enough
|
|
70
|
-
|
|
71
|
-
Write CRITIQUES.md in YOUR cwd (your own lab) with one section per
|
|
72
|
-
peer you reviewed. Severity-ranked findings with file:line citations
|
|
73
|
-
pointing at the OTHER peer's files. Be specific. Don't pile on,
|
|
74
|
-
don't rubber-stamp. Cite real lines, not vibes.
|
|
75
|
-
|
|
76
|
-
Then commit your critique:
|
|
77
|
-
git add CRITIQUES.md
|
|
78
|
-
git commit -m 'cross-review'"
|
|
79
|
-
|
|
80
|
-
After it returns, verify:
|
|
81
|
-
- .peerlab/{peer-id}/CRITIQUES.md exists and has non-trivial content
|
|
82
|
-
- git -C .peerlab/{peer-id} log -1 --oneline mentions 'cross-review'
|
|
83
|
-
|
|
84
|
-
Report back under 300 words: peer-id, ok=yes/no, duration, and a 1-line
|
|
85
|
-
summary of whose code this peer found most/least convincing.
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
Wait for every peer to finish cross-review before Step 4.
|
|
89
|
-
|
|
90
|
-
## Step 4 — Synthesize (one message to user, ≤300 words)
|
|
91
|
-
|
|
92
|
-
For each peer, read BOTH:
|
|
93
|
-
- The work diff: `git -C .peerlab/<peer-id> log -p HEAD~2..HEAD` (covers work commit + critique commit — 2 commits if both rounds ran, 1 if Step 3 was skipped).
|
|
94
|
-
- The critique: `.peerlab/<peer-id>/CRITIQUES.md` — what THIS peer thought of the OTHERS.
|
|
95
|
-
|
|
96
|
-
Then produce a tight team-lead synthesis that leans on the peers' own voices, not just yours:
|
|
97
|
-
|
|
98
|
-
- **Per-peer implementation summary**: 1–2 lines each — files touched, LOC delta, the shape of the approach.
|
|
99
|
-
- **What peers said about each other**: extract the sharpest findings from each `CRITIQUES.md`. If codex called out a bug in gemini's lab with a file:line, quote it. If gemini pointed at an edge case codex missed, quote it. These are the adversarial moments — don't bury them.
|
|
100
|
-
- **Cross-cutting themes**: where did peers agree about what's wrong? Where did they disagree? Disagreement is signal.
|
|
101
|
-
- **Honest head-to-head**: which lab made the cleanest move, informed by the peer critiques? Don't play favorites, don't hedge.
|
|
102
|
-
- **What to pull into alpha**: if a peer's approach is objectively better, name files + approach. Don't do the pull yourself — recommend.
|
|
103
|
-
|
|
104
|
-
No focus dirs. No consensus.md. Just the synthesis as your chat reply.
|
|
105
|
-
|
|
106
|
-
## Guardrails
|
|
107
|
-
|
|
108
|
-
- **Alpha (this session) does NOT edit `.peerlab/<peer-id>/`.** Peers own their labs. You only read.
|
|
109
|
-
- **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 + cross-review; I read the diffs and critiques."
|
|
110
|
-
- **Fire every peer** unless the user's prompt explicitly names `--peer`/`--peers`. Watchdog protects against hangs.
|
|
111
|
-
- **Do not skip Step 3.** The cross-review round is the point. If only one peer made it through Step 2, note why in the synthesis and skip Step 3 explicitly.
|
|
112
|
-
- **If a peer fails** (hung, errored, no commits), note it in the synthesis and continue. One flaky peer doesn't sink the run.
|
|
113
|
-
- **No AI attribution** in anything you write — see the maintainer's CLAUDE.md.
|
|
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
|
|
File without changes
|