paircode 0.7.1__py3-none-any.whl
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/__init__.py +5 -0
- paircode/__main__.py +4 -0
- paircode/cli.py +419 -0
- paircode/detect.py +52 -0
- paircode/drive.py +385 -0
- paircode/gates.py +62 -0
- paircode/handshake.py +55 -0
- paircode/installer.py +161 -0
- paircode/journey.py +63 -0
- paircode/runner.py +114 -0
- paircode/seal.py +52 -0
- paircode/state.py +162 -0
- paircode/templates/FOCUS.md +35 -0
- paircode/templates/JOURNEY.md +21 -0
- paircode/templates/claude_slash_command.md +23 -0
- paircode/templates/codex_rules.md +13 -0
- paircode/templates/peers.yaml +20 -0
- paircode-0.7.1.data/data/paircode/templates/FOCUS.md +35 -0
- paircode-0.7.1.data/data/paircode/templates/JOURNEY.md +21 -0
- paircode-0.7.1.data/data/paircode/templates/claude_slash_command.md +23 -0
- paircode-0.7.1.data/data/paircode/templates/codex_rules.md +13 -0
- paircode-0.7.1.data/data/paircode/templates/peers.yaml +20 -0
- paircode-0.7.1.dist-info/METADATA +146 -0
- paircode-0.7.1.dist-info/RECORD +27 -0
- paircode-0.7.1.dist-info/WHEEL +4 -0
- paircode-0.7.1.dist-info/entry_points.txt +2 -0
- paircode-0.7.1.dist-info/licenses/LICENSE +21 -0
paircode/drive.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Drive loop: research → plan → execute stages with peer review rounds.
|
|
2
|
+
|
|
3
|
+
Each stage round consists of:
|
|
4
|
+
1. Every peer (alpha + others) writes their v_N output in parallel (cold if N=1).
|
|
5
|
+
2. Every peer reviews alpha's v_N and writes review-round-N-{peer}-critiques-alpha.md.
|
|
6
|
+
3. Alpha reads all reviews and writes v_{N+1} (if more rounds requested).
|
|
7
|
+
|
|
8
|
+
Plan and execute stages follow the same pattern with different prompts.
|
|
9
|
+
Execute stage produces .md orchestration logs only (not actual code — the
|
|
10
|
+
full-fork peers' code still lives under .paircode/peers/peer-N-*/ and is
|
|
11
|
+
managed by the peer CLIs themselves, which may invoke their own tools).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import concurrent.futures
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Literal
|
|
19
|
+
|
|
20
|
+
from paircode.gates import check_convergence, check_human_gate
|
|
21
|
+
from paircode.runner import run_peer, PeerRunResult
|
|
22
|
+
from paircode.state import (
|
|
23
|
+
PaircodeState,
|
|
24
|
+
find_paircode,
|
|
25
|
+
init_paircode,
|
|
26
|
+
open_focus,
|
|
27
|
+
read_peers,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
StageName = Literal["research", "plan", "execute"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
COLD_PROMPTS: dict[StageName, str] = {
|
|
35
|
+
"research": """You are participating in a paircode peer-review cycle.
|
|
36
|
+
The topic for this focus is:
|
|
37
|
+
|
|
38
|
+
{topic}
|
|
39
|
+
|
|
40
|
+
This is the RESEARCH stage, round 1 (cold). Produce independent research on this
|
|
41
|
+
topic. Write a detailed markdown response covering:
|
|
42
|
+
|
|
43
|
+
1. Problem framing — what's actually being asked?
|
|
44
|
+
2. Prior art — what exists that's similar or informative?
|
|
45
|
+
3. Key questions that need to be answered before planning
|
|
46
|
+
4. Constraints and assumptions you're making
|
|
47
|
+
5. Initial directions worth exploring
|
|
48
|
+
|
|
49
|
+
Be honest, skeptical, specific. Your response is saved verbatim to disk as a
|
|
50
|
+
file-trace for other LLMs to read. Clean markdown, no preamble, just research.
|
|
51
|
+
""",
|
|
52
|
+
"plan": """You are participating in a paircode peer-review cycle.
|
|
53
|
+
The topic for this focus is:
|
|
54
|
+
|
|
55
|
+
{topic}
|
|
56
|
+
|
|
57
|
+
This is the PLAN stage, round 1 (cold). The research stage has already produced
|
|
58
|
+
outputs you can read at .paircode/{focus}/research/*-FINAL.md or *-v{last}.md.
|
|
59
|
+
Based on that research (and your own judgment), produce a concrete plan:
|
|
60
|
+
|
|
61
|
+
1. Goal (one sentence)
|
|
62
|
+
2. Scope (what's in, what's out)
|
|
63
|
+
3. Steps — numbered, each actionable
|
|
64
|
+
4. Risks and unknowns
|
|
65
|
+
5. Success criteria
|
|
66
|
+
|
|
67
|
+
Be KISS. LLMs tend to overbake plans — don't. Ship small, iterate.
|
|
68
|
+
""",
|
|
69
|
+
"execute": """You are participating in a paircode peer-review cycle.
|
|
70
|
+
The topic for this focus is:
|
|
71
|
+
|
|
72
|
+
{topic}
|
|
73
|
+
|
|
74
|
+
This is the EXECUTE stage, round 1. The plan stage outputs are at
|
|
75
|
+
.paircode/{focus}/plan/*-FINAL.md. Execute the plan (write code, run commands,
|
|
76
|
+
whatever the plan prescribes). Produce a markdown summary of what you did:
|
|
77
|
+
|
|
78
|
+
1. What you built / changed / ran
|
|
79
|
+
2. Files touched (with paths)
|
|
80
|
+
3. Tests / verification status
|
|
81
|
+
4. What's left open or blocked
|
|
82
|
+
|
|
83
|
+
This summary becomes a file-trace. If your CLI has file-write and command-exec
|
|
84
|
+
tools, use them freely — just report what happened in your response.
|
|
85
|
+
""",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
REVIEW_PROMPT_TEMPLATE = """You are a peer reviewer in a paircode cycle.
|
|
90
|
+
|
|
91
|
+
Read alpha's {stage} output at:
|
|
92
|
+
|
|
93
|
+
{alpha_file}
|
|
94
|
+
|
|
95
|
+
The topic for this focus is: {topic}
|
|
96
|
+
|
|
97
|
+
Produce an honest, skeptical review of alpha's work. Rank your findings by
|
|
98
|
+
severity (HIGH / MEDIUM / LOW). For each finding, cite alpha's output specifically
|
|
99
|
+
(line numbers or quoted phrases). Be generous with problems; be stingy with praise.
|
|
100
|
+
|
|
101
|
+
Your review will be saved to {review_path} so alpha can read it in the next
|
|
102
|
+
iteration round. Clean markdown. No preamble — just the ranked findings.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
ALPHA_REVISION_PROMPT_TEMPLATE = """You are alpha in a paircode cycle, writing
|
|
107
|
+
version {version} of your {stage} output for the focus on:
|
|
108
|
+
|
|
109
|
+
{topic}
|
|
110
|
+
|
|
111
|
+
Your prior version is at:
|
|
112
|
+
|
|
113
|
+
{alpha_prior}
|
|
114
|
+
|
|
115
|
+
The peer reviews of your prior version are at:
|
|
116
|
+
|
|
117
|
+
{reviews_list}
|
|
118
|
+
|
|
119
|
+
Read your prior version AND all the reviews. Incorporate valid criticisms,
|
|
120
|
+
defend positions where critics are wrong, and produce version {version}. This
|
|
121
|
+
is NOT a complete rewrite unless the critics are catastrophically right —
|
|
122
|
+
revise incrementally.
|
|
123
|
+
|
|
124
|
+
Clean markdown. No meta-commentary — just the revised output.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class StageResult:
|
|
130
|
+
focus_dir: Path
|
|
131
|
+
stage: str
|
|
132
|
+
version: int
|
|
133
|
+
peer_results: list[PeerRunResult] = field(default_factory=list)
|
|
134
|
+
review_results: list[PeerRunResult] = field(default_factory=list)
|
|
135
|
+
alpha_revision: PeerRunResult | None = None
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def successes(self) -> int:
|
|
139
|
+
return sum(
|
|
140
|
+
1
|
|
141
|
+
for r in self.peer_results + self.review_results + ([self.alpha_revision] if self.alpha_revision else [])
|
|
142
|
+
if r and r.ok
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def failures(self) -> int:
|
|
147
|
+
return sum(
|
|
148
|
+
1
|
|
149
|
+
for r in self.peer_results + self.review_results + ([self.alpha_revision] if self.alpha_revision else [])
|
|
150
|
+
if r and not r.ok
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _ensure_state() -> PaircodeState:
|
|
155
|
+
state = find_paircode()
|
|
156
|
+
if state is None:
|
|
157
|
+
state = init_paircode()
|
|
158
|
+
return state
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _format_prompt(stage: StageName, topic: str, focus_dir: Path) -> str:
|
|
162
|
+
return COLD_PROMPTS[stage].format(
|
|
163
|
+
topic=topic,
|
|
164
|
+
focus=focus_dir.name,
|
|
165
|
+
last="1",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def run_stage_cold(
|
|
170
|
+
state: PaircodeState,
|
|
171
|
+
focus_dir: Path,
|
|
172
|
+
stage: StageName,
|
|
173
|
+
topic: str,
|
|
174
|
+
alpha_cli: str = "claude",
|
|
175
|
+
alpha_model: str | None = None,
|
|
176
|
+
timeout_s: int = 600,
|
|
177
|
+
) -> StageResult:
|
|
178
|
+
"""Run cold v1 of a stage — alpha + all peers in parallel."""
|
|
179
|
+
stage_dir = focus_dir / stage
|
|
180
|
+
stage_dir.mkdir(exist_ok=True)
|
|
181
|
+
(stage_dir / "reviews").mkdir(exist_ok=True)
|
|
182
|
+
prompt = _format_prompt(stage, topic, focus_dir)
|
|
183
|
+
|
|
184
|
+
peers = read_peers(state)
|
|
185
|
+
jobs: list[tuple[str, str, str | None, Path]] = []
|
|
186
|
+
jobs.append(("alpha", alpha_cli, alpha_model, stage_dir / "alpha-v1.md"))
|
|
187
|
+
for p in peers:
|
|
188
|
+
jobs.append((p["id"], p["cli"], p.get("model"), stage_dir / f"{p['id']}-v1.md"))
|
|
189
|
+
|
|
190
|
+
results: list[PeerRunResult] = []
|
|
191
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, len(jobs))) as ex:
|
|
192
|
+
futures = {
|
|
193
|
+
ex.submit(run_peer, pid, cli, prompt, path, model=model, timeout_s=timeout_s): pid
|
|
194
|
+
for (pid, cli, model, path) in jobs
|
|
195
|
+
}
|
|
196
|
+
for fut in concurrent.futures.as_completed(futures):
|
|
197
|
+
results.append(fut.result())
|
|
198
|
+
|
|
199
|
+
return StageResult(
|
|
200
|
+
focus_dir=focus_dir,
|
|
201
|
+
stage=stage,
|
|
202
|
+
version=1,
|
|
203
|
+
peer_results=results,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def run_review_round(
|
|
208
|
+
state: PaircodeState,
|
|
209
|
+
focus_dir: Path,
|
|
210
|
+
stage: StageName,
|
|
211
|
+
topic: str,
|
|
212
|
+
version: int,
|
|
213
|
+
timeout_s: int = 600,
|
|
214
|
+
) -> list[PeerRunResult]:
|
|
215
|
+
"""Every peer reviews alpha's v_{version}. Writes to reviews/round-{version}-*.md."""
|
|
216
|
+
stage_dir = focus_dir / stage
|
|
217
|
+
reviews_dir = stage_dir / "reviews"
|
|
218
|
+
reviews_dir.mkdir(exist_ok=True)
|
|
219
|
+
alpha_file = stage_dir / f"alpha-v{version}.md"
|
|
220
|
+
if not alpha_file.exists():
|
|
221
|
+
raise FileNotFoundError(f"alpha output missing: {alpha_file}")
|
|
222
|
+
|
|
223
|
+
peers = read_peers(state)
|
|
224
|
+
jobs: list[tuple[str, str, str | None, str, Path]] = []
|
|
225
|
+
for p in peers:
|
|
226
|
+
review_path = reviews_dir / f"round-{version:02d}-{p['id']}-critiques-alpha.md"
|
|
227
|
+
prompt = REVIEW_PROMPT_TEMPLATE.format(
|
|
228
|
+
stage=stage,
|
|
229
|
+
alpha_file=alpha_file,
|
|
230
|
+
topic=topic,
|
|
231
|
+
review_path=review_path,
|
|
232
|
+
)
|
|
233
|
+
jobs.append((p["id"], p["cli"], p.get("model"), prompt, review_path))
|
|
234
|
+
|
|
235
|
+
results: list[PeerRunResult] = []
|
|
236
|
+
if not jobs:
|
|
237
|
+
return results
|
|
238
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=len(jobs)) as ex:
|
|
239
|
+
futures = {
|
|
240
|
+
ex.submit(run_peer, pid, cli, prompt, path, model=model, timeout_s=timeout_s): pid
|
|
241
|
+
for (pid, cli, model, prompt, path) in jobs
|
|
242
|
+
}
|
|
243
|
+
for fut in concurrent.futures.as_completed(futures):
|
|
244
|
+
results.append(fut.result())
|
|
245
|
+
return results
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def run_alpha_revision(
|
|
249
|
+
state: PaircodeState,
|
|
250
|
+
focus_dir: Path,
|
|
251
|
+
stage: StageName,
|
|
252
|
+
topic: str,
|
|
253
|
+
version: int,
|
|
254
|
+
alpha_cli: str = "claude",
|
|
255
|
+
alpha_model: str | None = None,
|
|
256
|
+
timeout_s: int = 600,
|
|
257
|
+
) -> PeerRunResult:
|
|
258
|
+
"""Alpha reads its own v_{version} and all peer reviews, writes v_{version+1}."""
|
|
259
|
+
stage_dir = focus_dir / stage
|
|
260
|
+
alpha_prior = stage_dir / f"alpha-v{version}.md"
|
|
261
|
+
reviews_dir = stage_dir / "reviews"
|
|
262
|
+
review_files = sorted(reviews_dir.glob(f"round-{version:02d}-*-critiques-alpha.md"))
|
|
263
|
+
reviews_list = "\n".join(f" - {p}" for p in review_files) or " (no reviews)"
|
|
264
|
+
|
|
265
|
+
prompt = ALPHA_REVISION_PROMPT_TEMPLATE.format(
|
|
266
|
+
version=version + 1,
|
|
267
|
+
stage=stage,
|
|
268
|
+
topic=topic,
|
|
269
|
+
alpha_prior=alpha_prior,
|
|
270
|
+
reviews_list=reviews_list,
|
|
271
|
+
)
|
|
272
|
+
out_path = stage_dir / f"alpha-v{version + 1}.md"
|
|
273
|
+
return run_peer(
|
|
274
|
+
peer_id="alpha",
|
|
275
|
+
cli=alpha_cli,
|
|
276
|
+
prompt=prompt,
|
|
277
|
+
output_path=out_path,
|
|
278
|
+
model=alpha_model,
|
|
279
|
+
timeout_s=timeout_s,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def run_stage(
|
|
284
|
+
topic: str,
|
|
285
|
+
focus_dir: Path,
|
|
286
|
+
stage: StageName,
|
|
287
|
+
rounds: int = 1,
|
|
288
|
+
alpha_cli: str = "claude",
|
|
289
|
+
alpha_model: str | None = None,
|
|
290
|
+
timeout_s: int = 600,
|
|
291
|
+
state: PaircodeState | None = None,
|
|
292
|
+
check_gates: bool = True,
|
|
293
|
+
) -> list[StageResult]:
|
|
294
|
+
"""Run a stage for `rounds` rounds. Round 1 is cold v1. Rounds 2+ do
|
|
295
|
+
review-then-revise cycles. Stops early on convergence or human-gate
|
|
296
|
+
sentinel unless check_gates=False. Returns one StageResult per round."""
|
|
297
|
+
if state is None:
|
|
298
|
+
state = _ensure_state()
|
|
299
|
+
if rounds < 1:
|
|
300
|
+
raise ValueError("rounds must be >= 1")
|
|
301
|
+
|
|
302
|
+
stage_dir = focus_dir / stage
|
|
303
|
+
|
|
304
|
+
# Check human gate BEFORE starting
|
|
305
|
+
if check_gates:
|
|
306
|
+
gate = check_human_gate(stage_dir)
|
|
307
|
+
if gate.stop:
|
|
308
|
+
# Still run cold v1 so we have something, then stop.
|
|
309
|
+
pass
|
|
310
|
+
|
|
311
|
+
# Round 1: cold v1
|
|
312
|
+
first = run_stage_cold(
|
|
313
|
+
state, focus_dir, stage, topic,
|
|
314
|
+
alpha_cli=alpha_cli, alpha_model=alpha_model, timeout_s=timeout_s,
|
|
315
|
+
)
|
|
316
|
+
out = [first]
|
|
317
|
+
|
|
318
|
+
# Rounds 2..N: review + revise, with gate checks between rounds
|
|
319
|
+
for r in range(2, rounds + 1):
|
|
320
|
+
if check_gates:
|
|
321
|
+
hg = check_human_gate(stage_dir)
|
|
322
|
+
if hg.stop:
|
|
323
|
+
break
|
|
324
|
+
prev_version = r - 1
|
|
325
|
+
reviews = run_review_round(
|
|
326
|
+
state, focus_dir, stage, topic, prev_version, timeout_s=timeout_s
|
|
327
|
+
)
|
|
328
|
+
revision = run_alpha_revision(
|
|
329
|
+
state, focus_dir, stage, topic, prev_version,
|
|
330
|
+
alpha_cli=alpha_cli, alpha_model=alpha_model, timeout_s=timeout_s,
|
|
331
|
+
)
|
|
332
|
+
out.append(StageResult(
|
|
333
|
+
focus_dir=focus_dir,
|
|
334
|
+
stage=stage,
|
|
335
|
+
version=r,
|
|
336
|
+
review_results=reviews,
|
|
337
|
+
alpha_revision=revision,
|
|
338
|
+
))
|
|
339
|
+
# Convergence check AFTER revision lands
|
|
340
|
+
if check_gates:
|
|
341
|
+
conv = check_convergence(stage_dir, r)
|
|
342
|
+
if conv.stop:
|
|
343
|
+
break
|
|
344
|
+
return out
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def drive_research(
|
|
348
|
+
topic: str,
|
|
349
|
+
focus_name: str | None = None,
|
|
350
|
+
alpha_cli: str = "claude",
|
|
351
|
+
alpha_model: str | None = None,
|
|
352
|
+
timeout_s: int = 600,
|
|
353
|
+
rounds: int = 1,
|
|
354
|
+
) -> list[StageResult]:
|
|
355
|
+
"""Open a focus, run research stage for `rounds` rounds."""
|
|
356
|
+
state = _ensure_state()
|
|
357
|
+
focus_dir = open_focus(state, focus_name or topic, prompt=topic)
|
|
358
|
+
return run_stage(
|
|
359
|
+
topic, focus_dir, "research",
|
|
360
|
+
rounds=rounds, alpha_cli=alpha_cli, alpha_model=alpha_model,
|
|
361
|
+
timeout_s=timeout_s, state=state,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def drive_full(
|
|
366
|
+
topic: str,
|
|
367
|
+
focus_name: str | None = None,
|
|
368
|
+
alpha_cli: str = "claude",
|
|
369
|
+
alpha_model: str | None = None,
|
|
370
|
+
timeout_s: int = 600,
|
|
371
|
+
research_rounds: int = 2,
|
|
372
|
+
plan_rounds: int = 2,
|
|
373
|
+
execute_rounds: int = 1,
|
|
374
|
+
) -> dict[str, list[StageResult]]:
|
|
375
|
+
"""Full loop: open focus, run research + plan + execute. Returns per-stage results."""
|
|
376
|
+
state = _ensure_state()
|
|
377
|
+
focus_dir = open_focus(state, focus_name or topic, prompt=topic)
|
|
378
|
+
results: dict[str, list[StageResult]] = {}
|
|
379
|
+
for stage, rounds in (("research", research_rounds), ("plan", plan_rounds), ("execute", execute_rounds)):
|
|
380
|
+
results[stage] = run_stage(
|
|
381
|
+
topic, focus_dir, stage, # type: ignore[arg-type]
|
|
382
|
+
rounds=rounds, alpha_cli=alpha_cli, alpha_model=alpha_model,
|
|
383
|
+
timeout_s=timeout_s, state=state,
|
|
384
|
+
)
|
|
385
|
+
return results
|
paircode/gates.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Gate detection — two kinds of signals that stop a stage loop early.
|
|
2
|
+
|
|
3
|
+
1. Convergence: alpha's v_N is essentially the same as v_{N-1}. No more work to do.
|
|
4
|
+
2. Human gate: captain dropped a `HUMAN-GATE-*.md` file into the stage dir.
|
|
5
|
+
|
|
6
|
+
Both return a GateSignal. run_stage checks after each review+revise round and
|
|
7
|
+
stops if either fires.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from difflib import SequenceMatcher
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# If alpha_v_{N} and alpha_v_{N-1} are this similar (by SequenceMatcher ratio),
|
|
17
|
+
# we declare convergence. 0.95 = 95% identical character-level — conservative
|
|
18
|
+
# enough to only stop when almost nothing changed.
|
|
19
|
+
DEFAULT_CONVERGENCE_THRESHOLD = 0.95
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class GateSignal:
|
|
24
|
+
stop: bool
|
|
25
|
+
reason: str # "converged" | "human_gate" | "max_rounds" | ""
|
|
26
|
+
detail: str = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check_convergence(
|
|
30
|
+
stage_dir: Path, version: int, threshold: float = DEFAULT_CONVERGENCE_THRESHOLD
|
|
31
|
+
) -> GateSignal:
|
|
32
|
+
"""Compare alpha-v{version}.md to alpha-v{version-1}.md. If similarity
|
|
33
|
+
exceeds threshold, signal stop."""
|
|
34
|
+
if version < 2:
|
|
35
|
+
return GateSignal(stop=False, reason="")
|
|
36
|
+
curr = stage_dir / f"alpha-v{version}.md"
|
|
37
|
+
prev = stage_dir / f"alpha-v{version - 1}.md"
|
|
38
|
+
if not curr.exists() or not prev.exists():
|
|
39
|
+
return GateSignal(stop=False, reason="")
|
|
40
|
+
curr_text = curr.read_text(encoding="utf-8", errors="ignore")
|
|
41
|
+
prev_text = prev.read_text(encoding="utf-8", errors="ignore")
|
|
42
|
+
ratio = SequenceMatcher(None, prev_text, curr_text).ratio()
|
|
43
|
+
if ratio >= threshold:
|
|
44
|
+
return GateSignal(
|
|
45
|
+
stop=True,
|
|
46
|
+
reason="converged",
|
|
47
|
+
detail=f"alpha-v{version} ~= alpha-v{version-1} (similarity {ratio:.3f} >= {threshold})",
|
|
48
|
+
)
|
|
49
|
+
return GateSignal(stop=False, reason="", detail=f"similarity {ratio:.3f}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_human_gate(stage_dir: Path) -> GateSignal:
|
|
53
|
+
"""If any HUMAN-GATE-*.md file is in the stage dir, signal stop."""
|
|
54
|
+
gates = sorted(stage_dir.glob("HUMAN-GATE-*.md"))
|
|
55
|
+
if gates:
|
|
56
|
+
return GateSignal(
|
|
57
|
+
stop=True,
|
|
58
|
+
reason="human_gate",
|
|
59
|
+
detail=f"found {len(gates)} gate file(s): "
|
|
60
|
+
+ ", ".join(g.name for g in gates),
|
|
61
|
+
)
|
|
62
|
+
return GateSignal(stop=False, reason="")
|
paircode/handshake.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Handshake: detect installed CLIs and propose a peer roster for peers.yaml.
|
|
2
|
+
|
|
3
|
+
alpha is always implicit (= the project itself), so handshake proposes only
|
|
4
|
+
the peers. The captain can edit peers.yaml before running the first stage.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
|
|
10
|
+
from paircode.detect import detect_all
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ProposedPeer:
|
|
15
|
+
id: str
|
|
16
|
+
cli: str
|
|
17
|
+
mode: str
|
|
18
|
+
priority: str
|
|
19
|
+
notes: str = ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Default ranking of peers when auto-proposing a roster.
|
|
23
|
+
# Alpha is typically Claude (the primary LLM the captain is already using),
|
|
24
|
+
# so Claude is skipped here — peers are the other voices.
|
|
25
|
+
_PEER_RANK: list[tuple[str, str, str, str]] = [
|
|
26
|
+
# (cli, default_mode, priority, notes)
|
|
27
|
+
("codex", "full-fork", "high", "Second opinion, good for silent-agreement hunts"),
|
|
28
|
+
("ollama", "full-fork", "medium", "Local unlimited; good if a capable model is pulled"),
|
|
29
|
+
("gemini", "opinion-only", "low", "Free tier — use for quick opinions, not full forks"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def propose_roster() -> list[ProposedPeer]:
|
|
34
|
+
"""Scan installed CLIs, return a ranked list of peers to populate peers.yaml."""
|
|
35
|
+
detected = detect_all()
|
|
36
|
+
proposed: list[ProposedPeer] = []
|
|
37
|
+
peer_letter = ord("a")
|
|
38
|
+
for cli, default_mode, priority, notes in _PEER_RANK:
|
|
39
|
+
info = detected.get(cli)
|
|
40
|
+
if info and info.installed:
|
|
41
|
+
proposed.append(
|
|
42
|
+
ProposedPeer(
|
|
43
|
+
id=f"peer-{chr(peer_letter)}-{cli}",
|
|
44
|
+
cli=cli,
|
|
45
|
+
mode=default_mode,
|
|
46
|
+
priority=priority,
|
|
47
|
+
notes=notes,
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
peer_letter += 1
|
|
51
|
+
return proposed
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def proposed_as_yaml_dicts(proposed: list[ProposedPeer]) -> list[dict]:
|
|
55
|
+
return [asdict(p) for p in proposed]
|
paircode/installer.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Install paircode's slash command / rules into the detected LLM CLIs.
|
|
2
|
+
|
|
3
|
+
Strategy per CLI:
|
|
4
|
+
- Claude Code: write `~/.claude/commands/paircode.md` (user-level global slash command).
|
|
5
|
+
- Codex: write `~/.codex/prompts/paircode.md` if codex supports it, otherwise write
|
|
6
|
+
a rules snippet at `~/.codex/rules/paircode.rules` that gets picked up as context.
|
|
7
|
+
- Gemini: no global slash-command primitive; install as a skill via `gemini skills install`
|
|
8
|
+
or write an extension manifest. Fallback: print instructions to invoke `paircode` from shell.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from importlib import resources
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from paircode.detect import detect_all, CliInfo
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class InstallResult:
|
|
22
|
+
cli_name: str
|
|
23
|
+
action: str # "installed", "skipped", "failed"
|
|
24
|
+
path: Path | None
|
|
25
|
+
message: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _read_template(name: str) -> str:
|
|
29
|
+
return resources.files("paircode.templates").joinpath(name).read_text(encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def install_claude(info: CliInfo) -> InstallResult:
|
|
33
|
+
if not info.installed:
|
|
34
|
+
return InstallResult(
|
|
35
|
+
cli_name="claude",
|
|
36
|
+
action="skipped",
|
|
37
|
+
path=None,
|
|
38
|
+
message=f"claude CLI not on PATH. {info.install_hint}",
|
|
39
|
+
)
|
|
40
|
+
commands_dir = info.config_dir / "commands"
|
|
41
|
+
commands_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
target = commands_dir / "paircode.md"
|
|
43
|
+
template = _read_template("claude_slash_command.md")
|
|
44
|
+
target.write_text(template, encoding="utf-8")
|
|
45
|
+
return InstallResult(
|
|
46
|
+
cli_name="claude",
|
|
47
|
+
action="installed",
|
|
48
|
+
path=target,
|
|
49
|
+
message=f"Wrote /paircode slash command to {target}. Use it from any Claude Code session.",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def install_codex(info: CliInfo) -> InstallResult:
|
|
54
|
+
if not info.installed:
|
|
55
|
+
return InstallResult(
|
|
56
|
+
cli_name="codex",
|
|
57
|
+
action="skipped",
|
|
58
|
+
path=None,
|
|
59
|
+
message=f"codex CLI not on PATH. {info.install_hint}",
|
|
60
|
+
)
|
|
61
|
+
# Codex doesn't have a user-facing slash-command dir; we write a rules snippet
|
|
62
|
+
# that codex reads as context in every session. This is graceful fallback.
|
|
63
|
+
rules_dir = info.config_dir / "rules"
|
|
64
|
+
rules_dir.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
target = rules_dir / "paircode.rules"
|
|
66
|
+
template = _read_template("codex_rules.md")
|
|
67
|
+
target.write_text(template, encoding="utf-8")
|
|
68
|
+
return InstallResult(
|
|
69
|
+
cli_name="codex",
|
|
70
|
+
action="installed",
|
|
71
|
+
path=target,
|
|
72
|
+
message=(
|
|
73
|
+
f"Wrote paircode rules to {target}. Codex will see paircode as an "
|
|
74
|
+
"available tool in its context. Invoke via `codex exec 'paircode ...'`."
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def install_gemini(info: CliInfo) -> InstallResult:
|
|
80
|
+
if not info.installed:
|
|
81
|
+
return InstallResult(
|
|
82
|
+
cli_name="gemini",
|
|
83
|
+
action="skipped",
|
|
84
|
+
path=None,
|
|
85
|
+
message=f"gemini CLI not on PATH. {info.install_hint}",
|
|
86
|
+
)
|
|
87
|
+
# Gemini CLI exposes `gemini skills install <source>`, but we don't want to
|
|
88
|
+
# depend on the gemini binary being on PATH at install time. Instead, we
|
|
89
|
+
# document the invocation pattern: gemini users call paircode from shell or
|
|
90
|
+
# via a skill installed manually. Write a reminder file at ~/.gemini/paircode.md.
|
|
91
|
+
info.config_dir.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
target = info.config_dir / "paircode.md"
|
|
93
|
+
template = _read_template("codex_rules.md") # same "you have paircode CLI" content
|
|
94
|
+
target.write_text(template, encoding="utf-8")
|
|
95
|
+
return InstallResult(
|
|
96
|
+
cli_name="gemini",
|
|
97
|
+
action="installed",
|
|
98
|
+
path=target,
|
|
99
|
+
message=(
|
|
100
|
+
f"Wrote paircode reference to {target}. Gemini users: invoke via "
|
|
101
|
+
"`gemini -p 'run paircode status'` or from shell directly. Full "
|
|
102
|
+
"skill-registration support lands in a later release."
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def install_all() -> list[InstallResult]:
|
|
108
|
+
detected = detect_all()
|
|
109
|
+
installers = {
|
|
110
|
+
"claude": install_claude,
|
|
111
|
+
"codex": install_codex,
|
|
112
|
+
"gemini": install_gemini,
|
|
113
|
+
}
|
|
114
|
+
results: list[InstallResult] = []
|
|
115
|
+
for name, info in detected.items():
|
|
116
|
+
installer = installers.get(name)
|
|
117
|
+
if not installer:
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
results.append(installer(info))
|
|
121
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
122
|
+
results.append(
|
|
123
|
+
InstallResult(
|
|
124
|
+
cli_name=name,
|
|
125
|
+
action="failed",
|
|
126
|
+
path=None,
|
|
127
|
+
message=f"{type(exc).__name__}: {exc}",
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
return results
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def uninstall_all() -> list[InstallResult]:
|
|
134
|
+
"""Remove paircode entries from every known CLI config dir (idempotent)."""
|
|
135
|
+
results: list[InstallResult] = []
|
|
136
|
+
paths = [
|
|
137
|
+
(Path.home() / ".claude" / "commands" / "paircode.md", "claude"),
|
|
138
|
+
(Path.home() / ".codex" / "rules" / "paircode.rules", "codex"),
|
|
139
|
+
(Path.home() / ".gemini" / "paircode.md", "gemini"),
|
|
140
|
+
]
|
|
141
|
+
for path, cli_name in paths:
|
|
142
|
+
if path.exists():
|
|
143
|
+
path.unlink()
|
|
144
|
+
results.append(
|
|
145
|
+
InstallResult(
|
|
146
|
+
cli_name=cli_name,
|
|
147
|
+
action="installed", # we removed it
|
|
148
|
+
path=path,
|
|
149
|
+
message=f"Removed {path}",
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
results.append(
|
|
154
|
+
InstallResult(
|
|
155
|
+
cli_name=cli_name,
|
|
156
|
+
action="skipped",
|
|
157
|
+
path=path,
|
|
158
|
+
message=f"Nothing to remove at {path}",
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
return results
|