paircode 0.12.4__tar.gz → 0.13.2__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.2}/.gitignore +4 -0
- {paircode-0.12.4 → paircode-0.13.2}/PKG-INFO +2 -2
- paircode-0.13.2/diary/004-gemini-silent-flash-downgrade.md +44 -0
- {paircode-0.12.4 → paircode-0.13.2}/pyproject.toml +2 -2
- {paircode-0.12.4 → paircode-0.13.2}/src/paircode/__init__.py +1 -1
- {paircode-0.12.4 → paircode-0.13.2}/src/paircode/cli.py +78 -19
- {paircode-0.12.4 → paircode-0.13.2}/src/paircode/handshake.py +6 -1
- {paircode-0.12.4 → paircode-0.13.2}/src/paircode/peerlab.py +109 -69
- {paircode-0.12.4 → paircode-0.13.2}/src/paircode/state.py +21 -0
- paircode-0.13.2/src/paircode/templates/claude/commands/peerlab.md +119 -0
- {paircode-0.12.4 → paircode-0.13.2}/src/paircode/templates/peers.yaml +5 -1
- paircode-0.13.2/tests/test_handshake.py +28 -0
- {paircode-0.12.4 → paircode-0.13.2}/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.2}/LICENSE +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/README.md +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/diary/001-step-a-architecture.md +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/diary/002-v0.10-release-pipeline.md +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/diary/003-arch-b-pivot-grappling.md +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/src/paircode/__main__.py +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/src/paircode/converge.py +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/src/paircode/detect.py +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/src/paircode/installer.py +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/src/paircode/runner.py +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/src/paircode/templates/FOCUS.md +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/src/paircode/templates/JOURNEY.md +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/src/paircode/templates/claude/commands/paircode.md +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/src/paircode/templates/codex/commands/paircode.md +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/src/paircode/templates/gemini/commands/paircode.toml +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/src/paircode/util.py +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/tests/__init__.py +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/tests/test_cli_smoke.py +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/tests/test_converge.py +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/tests/test_smoke.py +0 -0
- {paircode-0.12.4 → paircode-0.13.2}/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.2
|
|
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
|
|
@@ -21,7 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
21
21
|
Classifier: Topic :: Software Development
|
|
22
22
|
Requires-Python: >=3.10
|
|
23
23
|
Requires-Dist: click>=8.1
|
|
24
|
-
Requires-Dist: cliworker>=0.8.
|
|
24
|
+
Requires-Dist: cliworker>=0.8.5
|
|
25
25
|
Requires-Dist: pyyaml>=6.0
|
|
26
26
|
Requires-Dist: rich>=13.0
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Diary 004 — The silent free-flash downgrade: an unpinned peer rubber-stamped a consensus
|
|
2
|
+
|
|
3
|
+
**Stardate:** 2026-06-17
|
|
4
|
+
**Verified state:** `main`, 81 tests passing. Changes this entry: `handshake.py` (add `ProposedPeer.model`, correct the stale gemini note), `templates/peers.yaml` (model-field warning), `tests/test_handshake.py` (new).
|
|
5
|
+
**TL;DR:** paircode *already* supports per-peer model pinning end-to-end. The bug was that pinning is optional and invisible, and a misconfigured CLI fails **silently** — so a `peer-b-gemini` ran `gemini-2.0-flash-001` for an entire plan consensus while masquerading as a real fork peer.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What triggered this
|
|
10
|
+
|
|
11
|
+
During an awali working session, `/paircode` was used to review an implementation plan. The Gemini peer "converged" enthusiastically with alpha — suspiciously agreeable. The maintainer's instinct: *"I don't think it's running Gemini Pro... I needed the best model possible instead of small models that just agree with you."*
|
|
12
|
+
|
|
13
|
+
He was right. Investigation found the peer was running **`gemini-2.0-flash-001`** — the free-tier default — not a Pro model. Its "agreement" was flash-tier noise that polluted the consensus. The only load-bearing peer that session was Codex.
|
|
14
|
+
|
|
15
|
+
## Why it happened — four silent failures stacked
|
|
16
|
+
|
|
17
|
+
1. **gemini-cli auth was `oauth-personal`.** That ignores any API key entirely and uses the free Google login → flash. The paid key in the env was never consulted.
|
|
18
|
+
2. **The account was free tier.** `gemini-2.5-pro` returned `limit: 0` — free tier grants *zero* Pro requests, not "rate-limited," literally none. (Fixed by the maintainer adding $25 credit → tier-1 paid.)
|
|
19
|
+
3. **No model was pinned.** paircode passes no `-m` unless a peer has a `model:` field; the awali roster had none, so the CLI default (flash) won.
|
|
20
|
+
4. **Nothing surfaced any of it.** The file-trace header read `<!-- model: (default) -->` — technically honest, operationally a lie. A weak model rubber-stamping consensus is the worst silent failure for an adversarial-review tool.
|
|
21
|
+
|
|
22
|
+
## Key discoveries (the plumbing was already right)
|
|
23
|
+
|
|
24
|
+
- **paircode supports per-peer model pinning, fully wired:** `peers.yaml model:` → `cli.py:309 peer.get("model")` → `runner.run_peer(model=...)` → `cliworker.get_spec("gemini", model=...)` → `gemini -m <model>`. Proven by `cliworker/tests/test_registry.py:64-67`. No code was missing — only discoverability and a sane default.
|
|
25
|
+
- **cliworker already strips the stale key.** `registry.py` gemini spec: `env_strip=["GOOGLE_API_KEY", "GEMINI_API_KEY"]`. So even with a dead `AIza` key baked into the running Claude Code session, cliworker removes it before invoking gemini, which then falls through to `~/.gemini/.env`. **No shell restart or `~/.zshenv` edit is needed** — an earlier "you must restart" hypothesis was wrong and is retracted here.
|
|
26
|
+
- **gemini-cli env loader has two traps** (`chunk-*.js` `loadEnvironment`/`findEnvFile`): (a) dotenv **never overrides** an existing `process.env` var; (b) discovery returns the **first** `.env` walking up — a Laravel repo root `.env` wins unless `advanced.ignoreLocalEnv: true`. Both matter for getting the key to land.
|
|
27
|
+
- **There is no Gemini 3.5 Pro.** Pro and Flash version independently: Flash reached 3.5 (`gemini-3.5-flash`); the newest *Pro* is `gemini-3.1-pro-preview`. Cost magnitude (paid, /1M tok in/out, ≤200k): 3.1 Pro Preview $2/$12 · 2.5 Pro $1.25/$10 · 3.5 Flash $1.50/$9 (Flash output now near-Pro!) · 2.5 Flash $0.30/$2.50. A paircode peer call ≈ 1–5¢ regardless — cost is not the constraint, quality is.
|
|
28
|
+
|
|
29
|
+
## The fix (this repo)
|
|
30
|
+
|
|
31
|
+
The systemic root cause lived in `handshake.py`: `ProposedPeer` had no `model` field, so every auto-generated roster was born unpinned, and `_PEER_RANK` hardcoded the now-false belief *"gemini = Free tier, not for full forks."*
|
|
32
|
+
|
|
33
|
+
- `ProposedPeer` gains `model: str = ""`. Because `asdict()` includes defaulted fields, **every generated roster now emits `model: ''`** — the lever is visible instead of hidden. Empty stays safely falsy downstream (`cli.py` passes no `-m`).
|
|
34
|
+
- The stale gemini note is corrected to: *"Pin model: to a Pro tier — unpinned + a free/oauth login silently runs weak flash."* No model version is hardcoded (they rot fast — see "no 3.5 Pro").
|
|
35
|
+
- `templates/peers.yaml` documents the `model` field with the silent-downgrade warning.
|
|
36
|
+
- `tests/test_handshake.py` guards the new surface (model key present + pin round-trips).
|
|
37
|
+
|
|
38
|
+
External (not this repo, recorded for trace): the maintainer's gemini-cli was reconfigured — `settings.json` auth → `gemini-api-key`, `advanced.ignoreLocalEnv: true`, `~/.gemini/.env` holding the paid key. The awali `.paircode/peers.yaml` should add `model: gemini-3.1-pro-preview` to its gemini peer — that is the per-peer, paircode-native pin and supersedes any global settings.json model.
|
|
39
|
+
|
|
40
|
+
## Open paper cuts
|
|
41
|
+
|
|
42
|
+
- **Resolved model is still not surfaced.** The header shows `(default)` when unpinned; paircode can't report the model the CLI actually chose because cliworker doesn't return it. A future improvement: have cliworker echo the resolved model back in `CLIResult` so the file-trace records what *ran*, not what was *requested*. That would have caught this in seconds.
|
|
43
|
+
- **No preflight auth/tier check.** paircode can't tell a Pro peer from a free-flash one before spending a fork on it. A `paircode doctor`-style probe (cheap 1-token call asking tier) could warn "peer-b-gemini resolves to flash — pin a model?" at roster time.
|
|
44
|
+
- **`propose_roster()` still emits `model: ''` for all CLIs.** It does not suggest a concrete Pro default per CLI (deliberately, to avoid version rot). If model-name stability ever improves, a per-CLI recommended default could be added behind `_PEER_RANK`.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "paircode"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.13.2"
|
|
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" }
|
|
@@ -39,7 +39,7 @@ dependencies = [
|
|
|
39
39
|
"click>=8.1",
|
|
40
40
|
"pyyaml>=6.0",
|
|
41
41
|
"rich>=13.0",
|
|
42
|
-
"cliworker>=0.8.
|
|
42
|
+
"cliworker>=0.8.5",
|
|
43
43
|
]
|
|
44
44
|
|
|
45
45
|
[project.urls]
|
|
@@ -28,6 +28,7 @@ from paircode.handshake import propose_roster, proposed_as_yaml_dicts
|
|
|
28
28
|
from paircode.installer import install_all, uninstall_all
|
|
29
29
|
from paircode.runner import run_peer
|
|
30
30
|
from paircode.state import (
|
|
31
|
+
ensure_gitignore,
|
|
31
32
|
ensure_peer_dirs,
|
|
32
33
|
find_paircode,
|
|
33
34
|
init_paircode,
|
|
@@ -108,6 +109,9 @@ def ensure_scaffold() -> None:
|
|
|
108
109
|
if state is None:
|
|
109
110
|
state = init_paircode()
|
|
110
111
|
did_init = True
|
|
112
|
+
else:
|
|
113
|
+
# Retroactively protect projects whose .paircode/ predates this guard.
|
|
114
|
+
ensure_gitignore(state.project_root)
|
|
111
115
|
|
|
112
116
|
peers = read_peers(state)
|
|
113
117
|
proposed_count = 0
|
|
@@ -324,12 +328,15 @@ def peerlab() -> None:
|
|
|
324
328
|
@peerlab.command("ensure")
|
|
325
329
|
def peerlab_ensure() -> None:
|
|
326
330
|
"""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
|
|
331
|
+
creation; git init per lab; add .peerlab/ to outer .gitignore. Idempotent.
|
|
329
332
|
|
|
330
|
-
|
|
333
|
+
Uses .peerlab/peers.yaml (peerlab's own roster), not .paircode/.
|
|
334
|
+
"""
|
|
335
|
+
from paircode.peerlab import ensure_peer_labs, find_peerlab, init_peerlab
|
|
336
|
+
|
|
337
|
+
state = find_peerlab()
|
|
331
338
|
if state is None:
|
|
332
|
-
state =
|
|
339
|
+
state = init_peerlab()
|
|
333
340
|
results = ensure_peer_labs(state)
|
|
334
341
|
for r in results:
|
|
335
342
|
if r.status == "created":
|
|
@@ -350,14 +357,14 @@ def peerlab_ensure() -> None:
|
|
|
350
357
|
help="Apply cliworker speed flags")
|
|
351
358
|
def peerlab_invoke(peer_id: str, prompt: str, out_path: str | None, timeout: int, fast: bool) -> None:
|
|
352
359
|
"""Fire a peer CLI with cwd set to its lab. Peer works in-place."""
|
|
353
|
-
from paircode.peerlab import peer_lab_path
|
|
360
|
+
from paircode.peerlab import find_peerlab, peer_lab_path, read_peerlab_peers
|
|
354
361
|
|
|
355
|
-
state =
|
|
362
|
+
state = find_peerlab()
|
|
356
363
|
if state is None:
|
|
357
364
|
raise click.ClickException(
|
|
358
|
-
"No .
|
|
365
|
+
"No .peerlab/ found. Run `paircode peerlab ensure` first."
|
|
359
366
|
)
|
|
360
|
-
peers =
|
|
367
|
+
peers = read_peerlab_peers(state)
|
|
361
368
|
peer = next((p for p in peers if p.get("id") == peer_id), None)
|
|
362
369
|
if peer is None:
|
|
363
370
|
known = [p.get("id") for p in peers]
|
|
@@ -368,14 +375,21 @@ def peerlab_invoke(peer_id: str, prompt: str, out_path: str | None, timeout: int
|
|
|
368
375
|
f"Lab dir missing: {lab}. Run `paircode peerlab ensure` first."
|
|
369
376
|
)
|
|
370
377
|
|
|
371
|
-
# Preamble
|
|
378
|
+
# Preamble: peer knows where its lab is + that cross-reads outside the
|
|
379
|
+
# lab are allowed when the task says so (cross-review reads alpha's
|
|
380
|
+
# project root at ../../ and sibling peer labs at ../).
|
|
372
381
|
framed_prompt = (
|
|
373
|
-
f"You are the {peer_id} peer
|
|
374
|
-
f"Your
|
|
375
|
-
f"your work
|
|
376
|
-
f"the diff.
|
|
382
|
+
f"You are the {peer_id} peer in a paircode peerlab run.\n"
|
|
383
|
+
f"Your own lab is at {lab} — that is your cwd. You have your own `.git/`\n"
|
|
384
|
+
f"here; commit your work in this lab before finishing so the team lead\n"
|
|
385
|
+
f"can read the diff. Modify files ONLY inside your own lab.\n"
|
|
386
|
+
f"\n"
|
|
387
|
+
f"The task below may instruct you to READ outside your lab — e.g.,\n"
|
|
388
|
+
f"alpha's project root at `../../` (alpha's lab) or sibling peer labs\n"
|
|
389
|
+
f"at `../<other-peer-id>/`. Read-only cross-reads are expected during\n"
|
|
390
|
+
f"cross-review rounds. Do not write outside your own lab.\n"
|
|
377
391
|
f"\n"
|
|
378
|
-
f"
|
|
392
|
+
f"Task:\n"
|
|
379
393
|
f"{prompt}"
|
|
380
394
|
)
|
|
381
395
|
|
|
@@ -423,15 +437,15 @@ def peerlab_invoke(peer_id: str, prompt: str, out_path: str | None, timeout: int
|
|
|
423
437
|
@peerlab.command("list")
|
|
424
438
|
def peerlab_list() -> None:
|
|
425
439
|
"""Show peer labs with creation status + HEAD commit (if any)."""
|
|
426
|
-
from paircode.peerlab import peer_lab_path
|
|
440
|
+
from paircode.peerlab import find_peerlab, peer_lab_path, read_peerlab_peers
|
|
427
441
|
|
|
428
|
-
state =
|
|
442
|
+
state = find_peerlab()
|
|
429
443
|
if state is None:
|
|
430
|
-
click.echo("No .
|
|
444
|
+
click.echo("No .peerlab/ found. Run `paircode peerlab ensure` first.")
|
|
431
445
|
return
|
|
432
|
-
peers =
|
|
446
|
+
peers = read_peerlab_peers(state)
|
|
433
447
|
if not peers:
|
|
434
|
-
click.echo("No peers in roster.")
|
|
448
|
+
click.echo("No peers in .peerlab/peers.yaml roster.")
|
|
435
449
|
return
|
|
436
450
|
ptable = Table(show_header=True, header_style="bold")
|
|
437
451
|
ptable.add_column("peer")
|
|
@@ -458,6 +472,51 @@ def peerlab_list() -> None:
|
|
|
458
472
|
console.print(ptable)
|
|
459
473
|
|
|
460
474
|
|
|
475
|
+
@peerlab.command("roster")
|
|
476
|
+
@click.option("--alpha", "alpha_cli", default=None,
|
|
477
|
+
help="CLI acting as alpha (excluded from peers unless last resort).")
|
|
478
|
+
@click.option("--peer", "peer_filter", default=None,
|
|
479
|
+
help="Narrow to a single peer id. Silently falls back if missing.")
|
|
480
|
+
@click.option("--peers", "peers_filter", default=None,
|
|
481
|
+
help="Comma-separated peer ids. Silently falls back if none match.")
|
|
482
|
+
def peerlab_roster(alpha_cli: str | None, peer_filter: str | None, peers_filter: str | None) -> None:
|
|
483
|
+
"""Print peerlab peer ids, one per line — best-effort, never errors.
|
|
484
|
+
|
|
485
|
+
Reads .peerlab/peers.yaml (peerlab's own roster, fully independent of
|
|
486
|
+
.paircode/). Same filter semantics as `paircode roster`.
|
|
487
|
+
"""
|
|
488
|
+
from paircode.peerlab import find_peerlab, read_peerlab_peers
|
|
489
|
+
|
|
490
|
+
state = find_peerlab()
|
|
491
|
+
if state is None:
|
|
492
|
+
return
|
|
493
|
+
all_peers = [p for p in read_peerlab_peers(state) if p.get("id")]
|
|
494
|
+
|
|
495
|
+
if peers_filter:
|
|
496
|
+
wanted = [s.strip() for s in peers_filter.split(",") if s.strip()]
|
|
497
|
+
filtered = [p for p in all_peers if p.get("id") in wanted]
|
|
498
|
+
elif peer_filter:
|
|
499
|
+
filtered = [p for p in all_peers if p.get("id") == peer_filter]
|
|
500
|
+
else:
|
|
501
|
+
filtered = list(all_peers)
|
|
502
|
+
|
|
503
|
+
def _exclude_alpha(peers: list[dict]) -> list[dict]:
|
|
504
|
+
if not alpha_cli:
|
|
505
|
+
return peers
|
|
506
|
+
return [p for p in peers if p.get("cli") != alpha_cli]
|
|
507
|
+
|
|
508
|
+
result = _exclude_alpha(filtered)
|
|
509
|
+
if not result and (peer_filter or peers_filter):
|
|
510
|
+
result = _exclude_alpha(all_peers)
|
|
511
|
+
if not result:
|
|
512
|
+
result = list(all_peers)
|
|
513
|
+
|
|
514
|
+
for p in result:
|
|
515
|
+
pid = p.get("id")
|
|
516
|
+
if pid:
|
|
517
|
+
click.echo(pid)
|
|
518
|
+
|
|
519
|
+
|
|
461
520
|
# ---------------------------------------------------------------------------
|
|
462
521
|
# Bare paircode — show state
|
|
463
522
|
# ---------------------------------------------------------------------------
|
|
@@ -16,6 +16,11 @@ class ProposedPeer:
|
|
|
16
16
|
cli: str
|
|
17
17
|
priority: str
|
|
18
18
|
notes: str = ""
|
|
19
|
+
# Empty = let the CLI pick its own default model. Pin a specific model string
|
|
20
|
+
# (e.g. a Pro tier) to avoid the silent free-tier trap: an unpinned peer on a
|
|
21
|
+
# free/oauth login can quietly run a weak flash model and rubber-stamp consensus.
|
|
22
|
+
# Emitted into every generated roster so the field is discoverable, not hidden.
|
|
23
|
+
model: str = ""
|
|
19
24
|
|
|
20
25
|
|
|
21
26
|
# Default ranking of peers when auto-proposing a roster.
|
|
@@ -25,7 +30,7 @@ _PEER_RANK: list[tuple[str, str, str]] = [
|
|
|
25
30
|
# (cli, priority, notes)
|
|
26
31
|
("codex", "high", "Second opinion, good for silent-agreement hunts"),
|
|
27
32
|
("ollama", "medium", "Local unlimited; good if a capable model is pulled"),
|
|
28
|
-
("gemini", "low", "
|
|
33
|
+
("gemini", "low", "Pin model: to a Pro tier — unpinned + a free/oauth login silently runs weak flash"),
|
|
29
34
|
]
|
|
30
35
|
|
|
31
36
|
|
|
@@ -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
|
|
@@ -31,6 +31,8 @@ PEERS_FILE = "peers.yaml"
|
|
|
31
31
|
JOURNEY_FILE = "JOURNEY.md"
|
|
32
32
|
FOCUS_FILE = "FOCUS.md"
|
|
33
33
|
|
|
34
|
+
GITIGNORE_BLOCK_HEADER = "# paircode — adversarial multi-LLM peer review state"
|
|
35
|
+
|
|
34
36
|
|
|
35
37
|
def _now_iso() -> str:
|
|
36
38
|
return _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
@@ -127,6 +129,24 @@ def ensure_peer_dirs(state: PaircodeState, proposed: Iterable) -> list[Path]:
|
|
|
127
129
|
return created
|
|
128
130
|
|
|
129
131
|
|
|
132
|
+
def ensure_gitignore(project_root: Path) -> bool:
|
|
133
|
+
"""Append `.paircode/` to outer `.gitignore` if not already present.
|
|
134
|
+
|
|
135
|
+
Mirrors `peerlab.ensure_gitignore`. Idempotent; returns True when a line
|
|
136
|
+
is added, False when `.paircode/` (or `.paircode`) is already listed.
|
|
137
|
+
"""
|
|
138
|
+
gitignore = project_root / ".gitignore"
|
|
139
|
+
existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
|
|
140
|
+
for line in existing.splitlines():
|
|
141
|
+
s = line.strip()
|
|
142
|
+
if s in (".paircode/", ".paircode"):
|
|
143
|
+
return False
|
|
144
|
+
tail = existing.rstrip()
|
|
145
|
+
new_text = (tail + "\n\n" if tail else "") + GITIGNORE_BLOCK_HEADER + "\n.paircode/\n"
|
|
146
|
+
gitignore.write_text(new_text, encoding="utf-8")
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
|
|
130
150
|
def init_paircode(project_root: Path | None = None, force: bool = False) -> PaircodeState:
|
|
131
151
|
"""Bootstrap .paircode/ in `project_root` (or cwd). Returns the new state.
|
|
132
152
|
|
|
@@ -143,6 +163,7 @@ def init_paircode(project_root: Path | None = None, force: bool = False) -> Pair
|
|
|
143
163
|
)
|
|
144
164
|
root.mkdir(parents=True, exist_ok=True)
|
|
145
165
|
(root / "sandbox").mkdir(exist_ok=True)
|
|
166
|
+
ensure_gitignore(project_root)
|
|
146
167
|
|
|
147
168
|
journey = _render(
|
|
148
169
|
_read_template("JOURNEY.md"),
|