paircode 0.12.1__tar.gz → 0.12.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {paircode-0.12.1 → paircode-0.12.3}/PKG-INFO +1 -1
  2. {paircode-0.12.1 → paircode-0.12.3}/pyproject.toml +1 -1
  3. {paircode-0.12.1 → paircode-0.12.3}/src/paircode/__init__.py +1 -1
  4. {paircode-0.12.1 → paircode-0.12.3}/src/paircode/peerlab.py +61 -7
  5. {paircode-0.12.1 → paircode-0.12.3}/tests/test_peerlab.py +77 -0
  6. {paircode-0.12.1 → paircode-0.12.3}/.gitignore +0 -0
  7. {paircode-0.12.1 → paircode-0.12.3}/LICENSE +0 -0
  8. {paircode-0.12.1 → paircode-0.12.3}/README.md +0 -0
  9. {paircode-0.12.1 → paircode-0.12.3}/diary/001-step-a-architecture.md +0 -0
  10. {paircode-0.12.1 → paircode-0.12.3}/diary/002-v0.10-release-pipeline.md +0 -0
  11. {paircode-0.12.1 → paircode-0.12.3}/diary/003-arch-b-pivot-grappling.md +0 -0
  12. {paircode-0.12.1 → paircode-0.12.3}/src/paircode/__main__.py +0 -0
  13. {paircode-0.12.1 → paircode-0.12.3}/src/paircode/cli.py +0 -0
  14. {paircode-0.12.1 → paircode-0.12.3}/src/paircode/converge.py +0 -0
  15. {paircode-0.12.1 → paircode-0.12.3}/src/paircode/detect.py +0 -0
  16. {paircode-0.12.1 → paircode-0.12.3}/src/paircode/handshake.py +0 -0
  17. {paircode-0.12.1 → paircode-0.12.3}/src/paircode/installer.py +0 -0
  18. {paircode-0.12.1 → paircode-0.12.3}/src/paircode/runner.py +0 -0
  19. {paircode-0.12.1 → paircode-0.12.3}/src/paircode/state.py +0 -0
  20. {paircode-0.12.1 → paircode-0.12.3}/src/paircode/templates/FOCUS.md +0 -0
  21. {paircode-0.12.1 → paircode-0.12.3}/src/paircode/templates/JOURNEY.md +0 -0
  22. {paircode-0.12.1 → paircode-0.12.3}/src/paircode/templates/claude/commands/paircode.md +0 -0
  23. {paircode-0.12.1 → paircode-0.12.3}/src/paircode/templates/claude/commands/peerlab.md +0 -0
  24. {paircode-0.12.1 → paircode-0.12.3}/src/paircode/templates/codex/commands/paircode.md +0 -0
  25. {paircode-0.12.1 → paircode-0.12.3}/src/paircode/templates/gemini/commands/paircode.toml +0 -0
  26. {paircode-0.12.1 → paircode-0.12.3}/src/paircode/templates/peers.yaml +0 -0
  27. {paircode-0.12.1 → paircode-0.12.3}/src/paircode/util.py +0 -0
  28. {paircode-0.12.1 → paircode-0.12.3}/tests/__init__.py +0 -0
  29. {paircode-0.12.1 → paircode-0.12.3}/tests/test_cli_smoke.py +0 -0
  30. {paircode-0.12.1 → paircode-0.12.3}/tests/test_converge.py +0 -0
  31. {paircode-0.12.1 → paircode-0.12.3}/tests/test_smoke.py +0 -0
  32. {paircode-0.12.1 → paircode-0.12.3}/tests/test_state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paircode
3
- Version: 0.12.1
3
+ Version: 0.12.3
4
4
  Summary: Adversarial journey framework — orchestrate multiple LLM peers through research, plan, and execute stages with file-trace peer review.
5
5
  Project-URL: Homepage, https://github.com/starshipagentic/paircode
6
6
  Project-URL: Repository, https://github.com/starshipagentic/paircode
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "paircode"
7
- version = "0.12.1"
7
+ version = "0.12.3"
8
8
  description = "Adversarial journey framework — orchestrate multiple LLM peers through research, plan, and execute stages with file-trace peer review."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -2,4 +2,4 @@
2
2
 
3
3
  See README.md and diary/001-step-a-architecture.md for the full design.
4
4
  """
5
- __version__ = "0.12.0"
5
+ __version__ = "0.12.2"
@@ -41,6 +41,30 @@ SEED_EXCLUDES: tuple[str, ...] = (
41
41
 
42
42
  GITIGNORE_BLOCK_HEADER = "# peerlab — per-peer independent parallel labs"
43
43
 
44
+ # Defaults dropped into each new peer lab's own .gitignore so peer commits
45
+ # don't accidentally include Python bytecode, editor junk, OS cruft.
46
+ # If the lab inherited a .gitignore from the project root (rsync seed), we
47
+ # APPEND this block — user's rules win, these are a safety net.
48
+ LAB_GITIGNORE_BLOCK = """\
49
+ # peerlab defaults — python + editor junk (keep peer commits clean)
50
+ __pycache__/
51
+ *.py[cod]
52
+ *$py.class
53
+ *.egg-info/
54
+ .pytest_cache/
55
+ .mypy_cache/
56
+ .ruff_cache/
57
+ .tox/
58
+ .coverage
59
+ .coverage.*
60
+ htmlcov/
61
+ .DS_Store
62
+ .idea/
63
+ .vscode/
64
+ *.swp
65
+ """
66
+ LAB_GITIGNORE_MARKER = "peerlab defaults"
67
+
44
68
 
45
69
  @dataclass(frozen=True)
46
70
  class EnsureResult:
@@ -108,6 +132,25 @@ def _rsync_project(src: Path, dst: Path, excludes: Iterable[str] = SEED_EXCLUDES
108
132
  shutil.copytree(src, dst, ignore=_ignore)
109
133
 
110
134
 
135
+ def ensure_lab_gitignore(lab: Path) -> bool:
136
+ """Ensure the peer lab has Python/editor junk gitignored. Idempotent.
137
+
138
+ If the lab already has a `.gitignore` (inherited via rsync from the
139
+ project root), our block is appended so user rules win. If none exists
140
+ (greenfield), our block IS the .gitignore.
141
+
142
+ Returns True if the block was added this call, False if it was already
143
+ there (marker detection).
144
+ """
145
+ gitignore = lab / ".gitignore"
146
+ existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
147
+ if LAB_GITIGNORE_MARKER in existing:
148
+ return False
149
+ new_text = (existing.rstrip() + "\n\n" if existing.strip() else "") + LAB_GITIGNORE_BLOCK
150
+ gitignore.write_text(new_text, encoding="utf-8")
151
+ return True
152
+
153
+
111
154
  def _git_init(lab: Path) -> None:
112
155
  """`git init` inside `lab/`. No-op if already inited."""
113
156
  if (lab / ".git").exists():
@@ -121,17 +164,24 @@ def _git_init(lab: Path) -> None:
121
164
  subprocess.run(["git", "init", "--quiet", str(lab)], check=True)
122
165
 
123
166
 
124
- def _git_initial_commit(lab: Path, message: str = "initial seed from project root") -> bool:
167
+ def _git_initial_commit(
168
+ lab: Path, peer_id: str, message: str = "initial seed from project root"
169
+ ) -> bool:
125
170
  """Stage everything + commit. Returns True if a commit was made.
126
171
 
127
- Sets local-only user.name/email (doesn't touch global git config) so the
128
- commit is authored even on machines without a global identity.
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.
129
176
  """
130
177
  if not any(lab.iterdir()):
131
178
  return False
132
- # Local git config — scoped to this repo
133
- subprocess.run(["git", "-C", str(lab), "config", "user.name", "paircode-peerlab"], check=False)
134
- subprocess.run(["git", "-C", str(lab), "config", "user.email", "peerlab@paircode.local"], check=False)
179
+ # Local git config — scoped to this lab, doesn't touch global identity.
180
+ subprocess.run(["git", "-C", str(lab), "config", "user.name", peer_id], check=False)
181
+ subprocess.run(
182
+ ["git", "-C", str(lab), "config", "user.email", f"{peer_id}@peerlab.local"],
183
+ check=False,
184
+ )
135
185
  subprocess.run(["git", "-C", str(lab), "add", "-A"], check=False)
136
186
  # Commit only if there's something staged
137
187
  diff_check = subprocess.run(
@@ -183,8 +233,12 @@ def ensure_peer_labs(state: PaircodeState) -> list[EnsureResult]:
183
233
  lab.mkdir(exist_ok=True)
184
234
  # Seed from project root (might be empty for greenfield — rsync handles fine)
185
235
  _rsync_project(project_root, lab)
236
+ # Drop peerlab-default .gitignore so peer commits don't pull in
237
+ # __pycache__, .pytest_cache, editor junk etc. Appends if the lab
238
+ # already inherited a .gitignore from the project root.
239
+ ensure_lab_gitignore(lab)
186
240
  _git_init(lab)
187
- _git_initial_commit(lab, message="initial seed from project root")
241
+ _git_initial_commit(lab, pid, message="initial seed from project root")
188
242
  results.append(EnsureResult(peer_id=pid, lab_path=lab, status="created"))
189
243
  return results
190
244
 
@@ -11,8 +11,10 @@ from click.testing import CliRunner
11
11
  from paircode.cli import main
12
12
  from paircode.handshake import ProposedPeer
13
13
  from paircode.peerlab import (
14
+ LAB_GITIGNORE_MARKER,
14
15
  PEERLAB_DIRNAME,
15
16
  ensure_gitignore,
17
+ ensure_lab_gitignore,
16
18
  ensure_peer_labs,
17
19
  peer_lab_path,
18
20
  )
@@ -176,6 +178,81 @@ def test_paircode_peerlab_help_is_reachable():
176
178
  # Seed excludes honored
177
179
  # ---------------------------------------------------------------------------
178
180
 
181
+ def test_lab_gitignore_has_pycache_on_fresh_lab(tmp_path, monkeypatch):
182
+ """Fresh lab's .gitignore excludes __pycache__ and bytecode so peer
183
+ commits don't pull in build junk. Regression for v0.12.0 bug where
184
+ `git add -A` in peer labs committed .pyc files."""
185
+ _init_and_scaffold(tmp_path, monkeypatch)
186
+ state = find_paircode()
187
+ ensure_peer_labs(state)
188
+ lab = peer_lab_path(state, "peer-a-codex")
189
+ gitignore = lab / ".gitignore"
190
+ assert gitignore.exists()
191
+ content = gitignore.read_text()
192
+ assert "__pycache__/" in content
193
+ assert "*.py[cod]" in content
194
+ assert LAB_GITIGNORE_MARKER in content
195
+
196
+
197
+ def test_ensure_lab_gitignore_appends_to_existing(tmp_path):
198
+ """If user's project already has a .gitignore (rsync brought it over),
199
+ the peerlab block is APPENDED — user rules preserved."""
200
+ lab = tmp_path
201
+ (lab / ".gitignore").write_text("# user project rules\nsecrets.env\n")
202
+ assert ensure_lab_gitignore(lab) is True
203
+ content = (lab / ".gitignore").read_text()
204
+ assert "secrets.env" in content # user rule preserved
205
+ assert LAB_GITIGNORE_MARKER in content # our block added
206
+
207
+
208
+ def test_ensure_lab_gitignore_is_idempotent(tmp_path):
209
+ """Second call no-ops if marker already there."""
210
+ lab = tmp_path
211
+ assert ensure_lab_gitignore(lab) is True
212
+ before = (lab / ".gitignore").read_text()
213
+ assert ensure_lab_gitignore(lab) is False
214
+ assert (lab / ".gitignore").read_text() == before
215
+
216
+
217
+ def test_initial_commit_authored_as_peer_id(tmp_path, monkeypatch):
218
+ """Initial seed commit must be authored as the peer_id so replay
219
+ (git log) attributes work to each peer. Fixes v0.12.0 where commits
220
+ were authored as generic 'paircode-peerlab'."""
221
+ _init_and_scaffold(tmp_path, monkeypatch)
222
+ state = find_paircode()
223
+ ensure_peer_labs(state)
224
+ for pid in ("peer-a-codex", "peer-b-gemini"):
225
+ lab = peer_lab_path(state, pid)
226
+ r = subprocess.run(
227
+ ["git", "-C", str(lab), "log", "-1", "--format=%an <%ae>"],
228
+ capture_output=True, text=True, check=False,
229
+ )
230
+ assert pid in r.stdout, f"commit author should contain {pid}; got {r.stdout}"
231
+ assert f"{pid}@peerlab.local" in r.stdout
232
+
233
+
234
+ def test_different_peers_produce_different_initial_commits(tmp_path, monkeypatch):
235
+ """Distinct peer_ids as commit authors mean the seed commit SHAs differ
236
+ across labs — proves independence beyond 'same content, same author,
237
+ accidental SHA collision'."""
238
+ _init_and_scaffold(tmp_path, monkeypatch)
239
+ state = find_paircode()
240
+ ensure_peer_labs(state)
241
+ codex_lab = peer_lab_path(state, "peer-a-codex")
242
+ gemini_lab = peer_lab_path(state, "peer-b-gemini")
243
+
244
+ def head_sha(lab):
245
+ r = subprocess.run(
246
+ ["git", "-C", str(lab), "rev-parse", "HEAD"],
247
+ capture_output=True, text=True, check=False,
248
+ )
249
+ return r.stdout.strip()
250
+
251
+ assert head_sha(codex_lab) != head_sha(gemini_lab), (
252
+ "initial seed commits should differ across labs since authors differ"
253
+ )
254
+
255
+
179
256
  def test_seed_excludes_dot_paircode_and_dot_git(tmp_path, monkeypatch):
180
257
  """Seeded labs must not contain `.paircode/` (recursion) or the outer
181
258
  repo's `.git/` (each lab gets its own fresh git instead)."""
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes