paircode 0.10.1__tar.gz → 0.11.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.10.1 → paircode-0.11.1}/PKG-INFO +1 -1
- paircode-0.11.1/diary/002-v0.10-release-pipeline.md +126 -0
- paircode-0.11.1/diary/003-arch-b-pivot-grappling.md +133 -0
- {paircode-0.10.1 → paircode-0.11.1}/pyproject.toml +1 -1
- {paircode-0.10.1 → paircode-0.11.1}/src/paircode/__init__.py +1 -1
- paircode-0.11.1/src/paircode/cli.py +354 -0
- {paircode-0.10.1 → paircode-0.11.1}/src/paircode/handshake.py +6 -8
- {paircode-0.10.1 → paircode-0.11.1}/src/paircode/installer.py +43 -18
- {paircode-0.10.1 → paircode-0.11.1}/src/paircode/state.py +61 -7
- paircode-0.11.1/src/paircode/templates/claude/commands/paircode.md +169 -0
- paircode-0.11.1/src/paircode/templates/codex/commands/paircode.md +161 -0
- paircode-0.11.1/src/paircode/templates/gemini/commands/paircode.toml +152 -0
- {paircode-0.10.1 → paircode-0.11.1}/src/paircode/templates/peers.yaml +0 -1
- paircode-0.11.1/tests/test_cli_smoke.py +668 -0
- {paircode-0.10.1 → paircode-0.11.1}/tests/test_smoke.py +4 -18
- {paircode-0.10.1 → paircode-0.11.1}/tests/test_state.py +40 -1
- paircode-0.10.1/src/paircode/cli.py +0 -419
- paircode-0.10.1/src/paircode/drive.py +0 -385
- paircode-0.10.1/src/paircode/gates.py +0 -62
- paircode-0.10.1/src/paircode/templates/claude_slash_command.md +0 -23
- paircode-0.10.1/src/paircode/templates/codex_rules.md +0 -13
- paircode-0.10.1/src/paircode/templates/codex_slash_command.md +0 -24
- paircode-0.10.1/src/paircode/templates/gemini_slash_command.toml +0 -25
- paircode-0.10.1/tests/test_cli_smoke.py +0 -377
- paircode-0.10.1/tests/test_drive.py +0 -146
- paircode-0.10.1/tests/test_gates.py +0 -49
- {paircode-0.10.1 → paircode-0.11.1}/.gitignore +0 -0
- {paircode-0.10.1 → paircode-0.11.1}/LICENSE +0 -0
- {paircode-0.10.1 → paircode-0.11.1}/README.md +0 -0
- {paircode-0.10.1 → paircode-0.11.1}/diary/001-step-a-architecture.md +0 -0
- {paircode-0.10.1 → paircode-0.11.1}/src/paircode/__main__.py +0 -0
- {paircode-0.10.1 → paircode-0.11.1}/src/paircode/detect.py +0 -0
- {paircode-0.10.1 → paircode-0.11.1}/src/paircode/journey.py +0 -0
- {paircode-0.10.1 → paircode-0.11.1}/src/paircode/runner.py +0 -0
- {paircode-0.10.1 → paircode-0.11.1}/src/paircode/seal.py +0 -0
- {paircode-0.10.1 → paircode-0.11.1}/src/paircode/templates/FOCUS.md +0 -0
- {paircode-0.10.1 → paircode-0.11.1}/src/paircode/templates/JOURNEY.md +0 -0
- {paircode-0.10.1 → paircode-0.11.1}/tests/__init__.py +0 -0
- {paircode-0.10.1 → paircode-0.11.1}/tests/test_seal.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: paircode
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.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
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Diary 002 — v0.10 release pipeline + cliworker split + satellite repos
|
|
2
|
+
|
|
3
|
+
**Stardate:** 2026-04-18
|
|
4
|
+
**From:** v0.9.1 (file-drop install for all 3 CLIs)
|
|
5
|
+
**To:** v0.10.1 (native register via satellite repos, driven by `cliworker.invoke()`)
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Context — what v0.10 cracked open
|
|
10
|
+
|
|
11
|
+
Between v0.1 and v0.9 paircode's installer evolved through four bad implementations (broken codex rules file, per-CLI no-ops, file-drop, correct file-drop) before landing on the right answer in v0.10: use each vendor's own registration command, not file-drop.
|
|
12
|
+
|
|
13
|
+
Research (2026-04-18) confirmed both Codex and Gemini now have user-level slash-command registration:
|
|
14
|
+
- Codex 0.121.0 → `codex marketplace add <owner/repo>` (git-clones a marketplace repo)
|
|
15
|
+
- Gemini 0.35.3 → `gemini extensions install <url> --consent` (git-clones an extension repo)
|
|
16
|
+
|
|
17
|
+
Neither is deprecated (Codex has both `marketplace` and a now-deprecated `prompts/` file-drop). Industry pattern for tools installing their own slash commands → **native register when available**. So paircode joins that pattern.
|
|
18
|
+
|
|
19
|
+
## The three-repo architecture
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
~/dev/paircode Python package (main repo, pypi)
|
|
23
|
+
├── src/paircode/templates/
|
|
24
|
+
│ ├── claude_slash_command.md source of truth
|
|
25
|
+
│ ├── codex_slash_command.md ↓
|
|
26
|
+
│ └── gemini_slash_command.toml ↓
|
|
27
|
+
└── scripts/release.py auto-syncs to satellites
|
|
28
|
+
|
|
29
|
+
~/dev/paircode-codex Codex marketplace satellite (GitHub only)
|
|
30
|
+
├── .agents/plugins/marketplace.json
|
|
31
|
+
├── plugins/paircode/
|
|
32
|
+
│ ├── .codex-plugin/plugin.json
|
|
33
|
+
│ └── commands/paircode.md copied from paircode's template
|
|
34
|
+
└── README + LICENSE
|
|
35
|
+
|
|
36
|
+
~/dev/paircode-gemini Gemini extension satellite (GitHub only)
|
|
37
|
+
├── gemini-extension.json
|
|
38
|
+
├── commands/paircode.toml copied from paircode's template
|
|
39
|
+
└── README + LICENSE
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Zero Python in the satellites.** The Python `paircode` CLI lives once, on pypi. Satellites hold only the slash-command manifest each vendor expects. `/paircode` triggers a markdown/TOML prompt telling the host CLI to shell out to the `paircode` binary — which the user already installed via `pipx install paircode`.
|
|
43
|
+
|
|
44
|
+
## Install flow after v0.10
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pipx install paircode # Python binary, once
|
|
48
|
+
paircode install # registers /paircode in every detected LLM CLI
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`paircode install` does per CLI:
|
|
52
|
+
- **claude** — file-drop `~/.claude/commands/paircode.md` (unchanged, it's the native pattern)
|
|
53
|
+
- **codex** — `cliworker.invoke("codex", "marketplace", "add", "starshipagentic/paircode-codex")`
|
|
54
|
+
- **gemini** — `cliworker.invoke("gemini", "extensions", "install", "https://github.com/starshipagentic/paircode-gemini", "--consent")`
|
|
55
|
+
|
|
56
|
+
All three idempotent:
|
|
57
|
+
- claude overwrites file (harmless)
|
|
58
|
+
- codex greps `~/.codex/config.toml` for `[marketplaces.paircode]` before invoking
|
|
59
|
+
- gemini greps `gemini extensions list` for `paircode` before invoking
|
|
60
|
+
|
|
61
|
+
All three graceful on failure:
|
|
62
|
+
- If the `invoke()` call returns ok=False, installer prints the exact command for copy-paste + captured stderr
|
|
63
|
+
- Never silently succeeds with a non-working state
|
|
64
|
+
|
|
65
|
+
## Why `cliworker.invoke()` exists
|
|
66
|
+
|
|
67
|
+
cliworker already had `run(prompt, *clis, ...)` for LLM invocation. Admin commands like `codex marketplace add` aren't LLM invocations — no prompt, no API-key stripping, no CLAUDE_FAST, no fallback chain. But they DO want cliworker's subprocess plumbing: timeout, FileNotFoundError handling, `CLIResult` dataclass, skip-cache opt-in.
|
|
68
|
+
|
|
69
|
+
v0.8 of cliworker added `invoke(cli, *args, ...)` — same subprocess guts, no LLM semantics. Default `stdin=DEVNULL` so any unexpected interactive prompt fails fast instead of hanging (critical: Gemini's `extensions install` has a fallback "install via git clone?" prompt if the GitHub release is missing).
|
|
70
|
+
|
|
71
|
+
Paircode uses it. Navcom can use it later. Any tool that wants "run a CLI, get a CLIResult back" gets it for free.
|
|
72
|
+
|
|
73
|
+
## The release pipeline
|
|
74
|
+
|
|
75
|
+
`scripts/release.py` is the only command needed to cut a paircode release:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
cd ~/dev/paircode
|
|
79
|
+
./scripts/release.py 0.11.0 # explicit version
|
|
80
|
+
./scripts/release.py patch # auto-bump patch segment
|
|
81
|
+
./scripts/release.py minor
|
|
82
|
+
./scripts/release.py --dry-run 0.11.0 # preview, write nothing
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
What it does in order:
|
|
86
|
+
1. Preflight — working trees clean, tests pass, required tools present (git, starforge, gh)
|
|
87
|
+
2. Bump paircode version in `pyproject.toml` + `src/paircode/__init__.py`
|
|
88
|
+
3. Commit + tag + push paircode to GitHub
|
|
89
|
+
4. `starforge beam --pypi` → publishes to pypi (with user's auto-approved yes pipe)
|
|
90
|
+
5. For each satellite:
|
|
91
|
+
- Copy latest template from paircode's `src/paircode/templates/`
|
|
92
|
+
- Bump version in the satellite's manifest
|
|
93
|
+
- Commit + tag + push
|
|
94
|
+
- `starforge beam` (no --pypi, just GitHub)
|
|
95
|
+
- `gh release create` — **critical** for Gemini, which falls back to an interactive prompt when the release is missing
|
|
96
|
+
6. Print summary with every URL
|
|
97
|
+
|
|
98
|
+
One command = pypi, main GitHub repo, both satellite GitHub repos, both satellite GitHub releases, all version-locked.
|
|
99
|
+
|
|
100
|
+
## Open paper cuts (not shipping blockers)
|
|
101
|
+
|
|
102
|
+
- **`__version__` string drift** — starforge auto-bumps `pyproject.toml` version on publish (e.g., 0.10.0 → 0.10.1) but leaves `__init__.py` alone. Every release needs a manual follow-up commit to sync. The release.py script could detect starforge's bump and sync automatically; not implemented yet.
|
|
103
|
+
- **Codex has no uninstall CLI** — paircode's `uninstall` for codex edits `~/.codex/config.toml` directly (strips the `[marketplaces.paircode]` section). Fragile. If OpenAI adds `codex marketplace remove <name>`, migrate to that.
|
|
104
|
+
- **Gemini keychain warning** — on macOS Homebrew install, gemini prints `Keychain initialization encountered an error: Cannot find module '../build/Release/keytar.node'` before every command. It falls back to FileKeychain so everything still works. Not a paircode bug; noisy stderr captured in some idempotency checks. Could be suppressed.
|
|
105
|
+
|
|
106
|
+
## Key files — where to look when something changes
|
|
107
|
+
|
|
108
|
+
| Change this template | Edit this file | Satellites auto-sync on next release |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| Claude slash command | `src/paircode/templates/claude_slash_command.md` | (only Claude; no satellite) |
|
|
111
|
+
| Codex slash command | `src/paircode/templates/codex_slash_command.md` | `paircode-codex/plugins/paircode/commands/paircode.md` |
|
|
112
|
+
| Gemini slash command | `src/paircode/templates/gemini_slash_command.toml` | `paircode-gemini/commands/paircode.toml` |
|
|
113
|
+
| Runner (subprocess → LLM) | `src/paircode/runner.py` (delegates to `cliworker.run`/`run_fast`) | — |
|
|
114
|
+
| Installer behavior | `src/paircode/installer.py` (delegates to `cliworker.invoke`) | — |
|
|
115
|
+
| Release steps | `scripts/release.py` | — |
|
|
116
|
+
|
|
117
|
+
## Verified live (2026-04-18)
|
|
118
|
+
|
|
119
|
+
- `cliworker` 0.8.1 on pypi with `invoke()`
|
|
120
|
+
- `paircode` 0.10.1 on pypi, depends on `cliworker>=0.8.1`
|
|
121
|
+
- `starshipagentic/paircode-codex` at tag v0.10.0, GitHub release live
|
|
122
|
+
- `starshipagentic/paircode-gemini` at tag v0.10.0, GitHub release live
|
|
123
|
+
- End-to-end `paircode uninstall && paircode install` on dev machine: all three CLIs cleanly register/deregister, `/paircode` appears in each tool's slash menu
|
|
124
|
+
- 52 pytest green in paircode, 88 pytest green in cliworker
|
|
125
|
+
|
|
126
|
+
*End of diary 002. Next milestone targets: real-data validation, cross-project orchestration.*
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Diary 003 — Arch-B pivot: cold-use failure exposed Python-driven orchestrator as a regression
|
|
2
|
+
|
|
3
|
+
**Stardate:** 2026-04-20
|
|
4
|
+
**From:** v0.10.1 — Python-driven orchestrator (drive.py with ThreadPoolExecutor spawning fresh `claude -p` / `gemini -p` / `codex exec` subprocesses in parallel)
|
|
5
|
+
**Toward:** Arch B — interactive Claude session IS the team lead; Python shrinks to filesystem scaffolding + template rendering
|
|
6
|
+
|
|
7
|
+
**This diary is a preservation record, not a migration log.** No code has changed on `main` beyond this file. A companion branch `path-python-controls-stages` pins the current Python-driven state so we can return if Arch B underdelivers.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## What triggered this pivot
|
|
12
|
+
|
|
13
|
+
The maintainer ran `/paircode` cold in Claude Code after weeks away, trying to get a second opinion on an unrelated engineering question. He typed:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
/paircode use gemini for reserach only to 'i mean go look at their actual releases...'
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Four concrete failures in one invocation:
|
|
20
|
+
|
|
21
|
+
1. **Slash command pass-through failed.** `~/.claude/commands/paircode.md` instructs the LLM to pass args literally to the CLI. `paircode use gemini for research only to '...'` → `Error: No such command 'use'`. The slash command's own literal-pass-through instruction is the failing case.
|
|
22
|
+
2. **Fallback picked the wrong flag.** The previous session fell back to `paircode drive "<topic>" --alpha-cli gemini --research-only`. Wrong: `--alpha-cli` swaps the driver — it replaces Claude with Gemini as alpha. The maintainer wanted Claude (this session) to keep driving and Gemini to be added as a peer. Inverted alpha/peer.
|
|
23
|
+
3. **No primitive for "just get an opinion".** The architecture has `research → plan → execute`. "What does Gemini think of X" isn't any of those — it's review-of-existing. No native primitive. The previous session hacked it with `--research-only`, which is semantically wrong.
|
|
24
|
+
4. **Flag soup.** `--alpha-cli`, `--alpha-model`, `--timeout`, `--research-rounds`, `--plan-rounds`, `--execute-rounds`, `--research-only`. User has to pre-classify their task into flag combinations before typing the quoted prompt.
|
|
25
|
+
|
|
26
|
+
The redesign brief written mid-session (see conversation transcript 2026-04-20) names these as Gap 1–4.
|
|
27
|
+
|
|
28
|
+
## What the maintainer wants
|
|
29
|
+
|
|
30
|
+
Single verb CLI:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
paircode "<whatever you want>" # ~95% of invocations
|
|
34
|
+
paircode "<whatever>" --peer gemini # filter roster to one peer
|
|
35
|
+
paircode "<whatever>" --peers gemini,codex # subset
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The LLM reads the quoted prompt and picks the right stages itself. Stages (research / plan / execute / ask-or-review) become invisible templates the LLMs consult, not CLI verbs the user types.
|
|
39
|
+
|
|
40
|
+
Plus the philosophy, stated verbatim in the session: *"my philosophy is auto fucking install bro!"* — setup work like `init`, `handshake` happens silently inside the main verb. Saved as a feedback memory under `/Users/t/.claude/projects/-Users-t-dev-paircode/memory/feedback_auto_install.md`.
|
|
41
|
+
|
|
42
|
+
## The architectural fork — Arch A vs Arch B
|
|
43
|
+
|
|
44
|
+
The real discovery of this session is that today's `drive.py` architecture is **the opposite of what worked in the maintainer's original mlmodel repo.** Comparing the two:
|
|
45
|
+
|
|
46
|
+
| | Arch A — current paircode | Arch B — mlmodel pattern |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| Alpha identity | Fresh `claude -p` subprocess spawned by Python | Interactive Claude session where `/paircode` was typed |
|
|
49
|
+
| Alpha's context | None (headless, no memory, no session) | Full (session + user memory + project state) |
|
|
50
|
+
| Orchestrator | Python `ThreadPoolExecutor` in drive.py | The interactive Claude session itself, via its Bash + Agent tools |
|
|
51
|
+
| Claude peers | Fresh `claude -p` subprocess | In-process Agent tool subagents (background) |
|
|
52
|
+
| Codex/Gemini peers | `cliworker.run()` → shell subprocess | Bash subprocess |
|
|
53
|
+
| Runs after user closes Claude? | Yes (headless loop) | No (dies with session) |
|
|
54
|
+
| Python code surface | Large — drive.py, gates.py, ThreadPoolExecutor | Small — scaffold + template render |
|
|
55
|
+
|
|
56
|
+
**Evidence for Arch B being what the maintainer originally built:**
|
|
57
|
+
|
|
58
|
+
- `/Users/t/clients/syra/mlmodel/.claude/commands/peerkickoff.md` — team lead is the interactive Claude, spawns two Agent-tool subagents in parallel with `run_in_background=true`, then synthesizes.
|
|
59
|
+
- `/Users/t/clients/syra/mlmodel/.claude/commands/audit-me.md` — interactive Claude spawns one subagent that shells out to codex CLI for peer review.
|
|
60
|
+
- `/Users/t/clients/syra/mlmodel/.claude/commands/audit-codex.md` — interactive Claude does PART 1 (review) itself with full context, then spawns a subagent to fire codex for self-heal.
|
|
61
|
+
- **Zero Python orchestrator binary in mlmodel.** Slash command markdown + Bash + Agent tool handles everything.
|
|
62
|
+
|
|
63
|
+
The maintainer confirmed this was his "worked fucking great" baseline. Today's paircode drive.py is a regression introduced during vibe-coding that built a Python thread pool which spawns the very `claude -p` subprocess that strips alpha of its session context.
|
|
64
|
+
|
|
65
|
+
## Decisions made in this session
|
|
66
|
+
|
|
67
|
+
**CLI surface (user-facing):**
|
|
68
|
+
- `paircode "<prompt>"` — the one verb.
|
|
69
|
+
- `paircode` bare — prints current state (eats what `status` used to do).
|
|
70
|
+
- `paircode install` / `paircode uninstall` — stay. One-time setup that writes to `~/.claude`, `~/.codex`, `~/.gemini`. Maintainer explicitly said this is fine because users self-selected into it by installing paircode.
|
|
71
|
+
- **Dies as user verbs:** `init`, `handshake`, `status`, `focus`, `stage`, `seal`, `drive`.
|
|
72
|
+
|
|
73
|
+
**Auto-silent preconditions inside the main verb:**
|
|
74
|
+
- If `.paircode/` missing → `init_paircode()` runs silently.
|
|
75
|
+
- If `peers.yaml` empty / stale → handshake runs silently.
|
|
76
|
+
- Focus creation is procedural (`open_focus()` in state.py), always-new-per-invocation by default, escape hatch `--continue` to reuse the most recent unsealed focus when the previous team lead flaked.
|
|
77
|
+
|
|
78
|
+
**Semantic decisions vs mechanical decisions:**
|
|
79
|
+
- LLMs decide: is this a new topic? is this round converged? which focus to continue? who gets which stage?
|
|
80
|
+
- Python decides: where files go on disk, how directories are scaffolded, template rendering, roster persistence.
|
|
81
|
+
|
|
82
|
+
**Sealing + consensus (the maintainer's design):**
|
|
83
|
+
- Team lead (interactive Claude) runs multiple rounds from the one `/paircode` invocation.
|
|
84
|
+
- When the team lead has heard enough, it tells each peer to write their `{peer}-FINAL.md`.
|
|
85
|
+
- Team lead then writes its own `alpha-FINAL.md`.
|
|
86
|
+
- Team lead reads all FINALs and writes a **new artifact: `consensus.md`** — the very last thing before closing out the focus.
|
|
87
|
+
- **Current prompts in drive.py do NOT describe any of this.** The maintainer had believed sealing logic was already in the prompts; verification showed it is not. This is a real prompt authoring task, not a memory of existing work.
|
|
88
|
+
|
|
89
|
+
**Capability syntax rejected:**
|
|
90
|
+
- Brief proposed `--peer gemini:opine` / `--peer codex:work` for inline capability override.
|
|
91
|
+
- Maintainer explicitly dislikes this. Capability stays in `.paircode/peers.yaml` only; edit the file if you want to change it. No per-invocation override flag.
|
|
92
|
+
|
|
93
|
+
## Open tensions we are grappling with
|
|
94
|
+
|
|
95
|
+
1. **Headless execution.** Arch A can run for an hour unattended after the user closes Claude. Arch B dies with the session. mlmodel worked fine without headless, but this is a genuine capability loss. Does it matter for the paircode use cases? Unresolved. Preservation branch exists partly because of this.
|
|
96
|
+
2. **Claude-reviewing-Claude support.** mlmodel used Agent tool subagents for claude-on-claude peer review. paircode's current thesis is cross-vendor adversarial (different engines surface different blind spots). Do we keep Claude-on-Claude as an option or cut it for simplicity? Leaning toward cut, not decided.
|
|
97
|
+
3. **Consensus.md prompt authoring.** The final-round team-lead prompt needs to instruct: seal each peer, write your own FINAL, read all FINALs, write `consensus.md`, report. Not drafted yet.
|
|
98
|
+
4. **Codex + Gemini as team lead.** Slash commands are installed via satellites for all three CLIs. If `/paircode` is invoked inside Codex or Gemini (not Claude), can those hosts run the Arch B orchestration? Codex has subagent primitives; Gemini's are different. Cross-host parity for Arch B is an open question. Without parity, paircode is effectively Claude-first with Codex/Gemini as peers only.
|
|
99
|
+
5. **`cliworker` dependency.** If the slash command does all subprocess work via its host CLI's Bash tool, `cliworker.run()` leaves the runtime path. cliworker still matters for `paircode install` (marketplace add, extensions install via `invoke()`). The LLM-call speed-flag logic currently in cliworker.run becomes dead code from paircode's perspective.
|
|
100
|
+
6. **Testability.** Today's drive.py has unit tests (`tests/test_drive.py`). Arch B moves orchestration into slash command markdown, which is untestable by pytest. Smoke tests would have to shell out through a real LLM CLI — slow, flaky, costly. Mitigation: keep the procedural Python helpers (`init_paircode`, `open_focus`, template render, seal_stage) testable in isolation, and accept that the orchestration loop itself becomes an integration-test concern.
|
|
101
|
+
|
|
102
|
+
## Why we're preserving now
|
|
103
|
+
|
|
104
|
+
Arch B is a bet. It matches the maintainer's philosophy, it matches the pattern that worked in mlmodel, it dissolves Gap 1–4 cleanly. But it trades away:
|
|
105
|
+
|
|
106
|
+
- Headless execution
|
|
107
|
+
- A testable orchestration loop
|
|
108
|
+
- Cross-host parity guarantees (Codex/Gemini may not support the team-lead pattern as cleanly)
|
|
109
|
+
|
|
110
|
+
If any of those turn out to matter more than they look like they do today, we come back to this branch. The Python-driven orchestrator at v0.10.1 is a working, shipped system with 52 green tests. Killing it without preservation would be a mistake.
|
|
111
|
+
|
|
112
|
+
## Preservation details
|
|
113
|
+
|
|
114
|
+
- Branch `path-python-controls-stages` cut from `main` immediately after this diary commits.
|
|
115
|
+
- `main` continues toward the Arch B rewrite.
|
|
116
|
+
- The `drive` command, `run_stage`, `run_stage_cold`, `run_review_round`, `run_alpha_revision`, and the ThreadPoolExecutor orchestration in `drive.py` are expected to be deleted from `main` during the rewrite. They remain intact on the preservation branch.
|
|
117
|
+
- `state.py` (procedural scaffolding) stays on `main` unchanged — Arch B still uses it.
|
|
118
|
+
- `runner.py` / `cliworker` integration: fate undecided. See tension #5.
|
|
119
|
+
|
|
120
|
+
## Key files as of this diary
|
|
121
|
+
|
|
122
|
+
| Concern | File | Fate in Arch B |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `/paircode` slash command (Claude) | `src/paircode/templates/claude_slash_command.md` | **Rewritten** — becomes the orchestrator |
|
|
125
|
+
| `/paircode` slash command (Codex) | `src/paircode/templates/codex_slash_command.md` | Rewritten; parity with Claude version tbd |
|
|
126
|
+
| `/paircode` slash command (Gemini) | `src/paircode/templates/gemini_slash_command.toml` | Rewritten; parity tbd |
|
|
127
|
+
| Python CLI | `src/paircode/cli.py` | Reduced to `paircode "<prompt>"`, `install`, `uninstall`, bare-state |
|
|
128
|
+
| Drive loop | `src/paircode/drive.py` | **Deleted** on main, preserved on branch |
|
|
129
|
+
| State scaffolding | `src/paircode/state.py` | Kept |
|
|
130
|
+
| Runner | `src/paircode/runner.py` | Undecided |
|
|
131
|
+
| Seal | `src/paircode/seal.py` | Kept as a helper the slash command can call silently |
|
|
132
|
+
|
|
133
|
+
*End of diary 003. Next session: draft the rewritten slash command template + the trimmed Python CLI.*
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "paircode"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.11.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" }
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""paircode CLI — thin helpers the team-lead slash command calls.
|
|
2
|
+
|
|
3
|
+
Arch B (v0.11+): orchestration lives in the slash command installed inside
|
|
4
|
+
each LLM CLI. This binary exposes only the mechanical helpers the team lead
|
|
5
|
+
needs: scaffold, focus creation, roster listing, peer invocation, plus the
|
|
6
|
+
one-time `install` / `uninstall` lifecycle.
|
|
7
|
+
|
|
8
|
+
Subcommands:
|
|
9
|
+
install — register /paircode in every detected LLM CLI
|
|
10
|
+
uninstall — remove /paircode from LLM CLIs (idempotent)
|
|
11
|
+
ensure-scaffold — idempotent init + handshake, silent on success
|
|
12
|
+
focus new <slug> — create a new focus dir, print its path
|
|
13
|
+
focus active — print the active focus path
|
|
14
|
+
roster — print peer ids, one per line
|
|
15
|
+
invoke <id> <p> — run one peer CLI, write output file-trace to --out
|
|
16
|
+
(bare) — print current state (what `status` used to do)
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
import click
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
from rich.table import Table
|
|
25
|
+
|
|
26
|
+
from paircode import __version__
|
|
27
|
+
from paircode.handshake import propose_roster, proposed_as_yaml_dicts
|
|
28
|
+
from paircode.installer import install_all, uninstall_all
|
|
29
|
+
from paircode.runner import run_peer
|
|
30
|
+
from paircode.state import (
|
|
31
|
+
ensure_peer_dirs,
|
|
32
|
+
find_paircode,
|
|
33
|
+
init_paircode,
|
|
34
|
+
open_focus,
|
|
35
|
+
read_peers,
|
|
36
|
+
write_peers,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
console = Console()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@click.group(invoke_without_command=True)
|
|
44
|
+
@click.version_option(__version__, prog_name="paircode")
|
|
45
|
+
@click.pass_context
|
|
46
|
+
def main(ctx: click.Context) -> None:
|
|
47
|
+
"""paircode — adversarial multi-LLM peer review.
|
|
48
|
+
|
|
49
|
+
Orchestration runs inside the /paircode slash command of whichever LLM CLI
|
|
50
|
+
you invoked it from. This binary is the helper layer the team lead calls.
|
|
51
|
+
|
|
52
|
+
First-time setup: `paircode install` registers /paircode in Claude, Codex, Gemini.
|
|
53
|
+
"""
|
|
54
|
+
if ctx.invoked_subcommand is None:
|
|
55
|
+
_show_state()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Install / uninstall
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
@main.command()
|
|
63
|
+
def install() -> None:
|
|
64
|
+
"""Install /paircode slash command into every detected LLM CLI."""
|
|
65
|
+
console.print("[bold]Installing paircode into detected LLM CLIs...[/bold]\n")
|
|
66
|
+
results = install_all()
|
|
67
|
+
table = Table(show_header=True, header_style="bold")
|
|
68
|
+
table.add_column("CLI")
|
|
69
|
+
table.add_column("Action")
|
|
70
|
+
table.add_column("Details")
|
|
71
|
+
for r in results:
|
|
72
|
+
color = {"installed": "green", "skipped": "yellow",
|
|
73
|
+
"failed": "red", "already": "cyan"}.get(r.action, "white")
|
|
74
|
+
table.add_row(r.cli_name, f"[{color}]{r.action}[/{color}]", r.message)
|
|
75
|
+
console.print(table)
|
|
76
|
+
installed = sum(1 for r in results if r.action == "installed")
|
|
77
|
+
skipped = sum(1 for r in results if r.action == "skipped")
|
|
78
|
+
failed = sum(1 for r in results if r.action == "failed")
|
|
79
|
+
already = sum(1 for r in results if r.action == "already")
|
|
80
|
+
console.print(
|
|
81
|
+
f"\n[bold]Summary:[/bold] {installed} installed, {already} already, "
|
|
82
|
+
f"{skipped} skipped, {failed} failed."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@main.command()
|
|
87
|
+
def uninstall() -> None:
|
|
88
|
+
"""Remove /paircode from every LLM CLI config dir (idempotent)."""
|
|
89
|
+
console.print("[bold]Uninstalling paircode from LLM CLIs...[/bold]\n")
|
|
90
|
+
results = uninstall_all()
|
|
91
|
+
for r in results:
|
|
92
|
+
console.print(f" [dim]{r.cli_name}:[/dim] {r.message}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Scaffold — idempotent init + handshake
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
@main.command(name="ensure-scaffold")
|
|
100
|
+
def ensure_scaffold() -> None:
|
|
101
|
+
"""Idempotent: init .paircode/ if missing, run handshake if peers empty.
|
|
102
|
+
|
|
103
|
+
Silent on success. Prints one line per action taken (init, handshake).
|
|
104
|
+
The team-lead slash command calls this before every focus.
|
|
105
|
+
"""
|
|
106
|
+
state = find_paircode()
|
|
107
|
+
did_init = False
|
|
108
|
+
if state is None:
|
|
109
|
+
state = init_paircode()
|
|
110
|
+
did_init = True
|
|
111
|
+
|
|
112
|
+
peers = read_peers(state)
|
|
113
|
+
proposed_count = 0
|
|
114
|
+
if not peers:
|
|
115
|
+
proposed = propose_roster()
|
|
116
|
+
if proposed:
|
|
117
|
+
write_peers(state, proposed_as_yaml_dicts(proposed))
|
|
118
|
+
ensure_peer_dirs(state, proposed)
|
|
119
|
+
proposed_count = len(proposed)
|
|
120
|
+
|
|
121
|
+
if did_init:
|
|
122
|
+
click.echo(f"init {state.root}")
|
|
123
|
+
if proposed_count:
|
|
124
|
+
click.echo(f"handshake wrote {proposed_count} peers to {state.peers_path}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# Focus — new / active
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
@main.group()
|
|
132
|
+
def focus() -> None:
|
|
133
|
+
"""Focus commands (new, active)."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@focus.command("new")
|
|
137
|
+
@click.argument("slug")
|
|
138
|
+
@click.option("--prompt", default=None, help="One-line focus prompt to embed in FOCUS.md")
|
|
139
|
+
def focus_new(slug: str, prompt: str | None) -> None:
|
|
140
|
+
"""Create a new focus dir, print its path to stdout."""
|
|
141
|
+
state = find_paircode()
|
|
142
|
+
if state is None:
|
|
143
|
+
state = init_paircode()
|
|
144
|
+
try:
|
|
145
|
+
focus_dir = open_focus(state, slug, prompt=prompt)
|
|
146
|
+
except FileExistsError as exc:
|
|
147
|
+
raise click.ClickException(str(exc)) from exc
|
|
148
|
+
click.echo(str(focus_dir))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@focus.command("active")
|
|
152
|
+
def focus_active() -> None:
|
|
153
|
+
"""Print the most recent focus path, or exit 1 if none exists."""
|
|
154
|
+
state = find_paircode()
|
|
155
|
+
if state is None or state.active_focus is None:
|
|
156
|
+
raise click.ClickException(
|
|
157
|
+
"No focus found. Run `paircode focus new <slug>` first."
|
|
158
|
+
)
|
|
159
|
+
click.echo(str(state.active_focus))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
# Roster — print peer ids
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
@main.command()
|
|
167
|
+
@click.option("--alpha", "alpha_cli", default=None,
|
|
168
|
+
help="CLI acting as alpha (excluded from peers unless last resort).")
|
|
169
|
+
@click.option("--peer", "peer_filter", default=None,
|
|
170
|
+
help="Narrow to a single peer id. Silently falls back if missing.")
|
|
171
|
+
@click.option("--peers", "peers_filter", default=None,
|
|
172
|
+
help="Comma-separated peer ids. Silently falls back if none match.")
|
|
173
|
+
def roster(alpha_cli: str | None, peer_filter: str | None, peers_filter: str | None) -> None:
|
|
174
|
+
"""Print peer ids, one per line — best-effort, never errors.
|
|
175
|
+
|
|
176
|
+
Resolution order:
|
|
177
|
+
1. Apply --peer/--peers filter to the peers.yaml roster.
|
|
178
|
+
2. If --alpha is given, drop peers whose cli matches (except as last resort).
|
|
179
|
+
3. If the result is empty, fall back to "all peers except alpha".
|
|
180
|
+
4. If still empty, re-run handshake detection in case new CLIs landed.
|
|
181
|
+
5. If still empty and an alpha-cli peer exists, emit that as last resort.
|
|
182
|
+
6. If still empty, emit nothing — team lead must handle.
|
|
183
|
+
"""
|
|
184
|
+
state = find_paircode()
|
|
185
|
+
if state is None:
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
all_peers = [p for p in read_peers(state) if p.get("id")]
|
|
189
|
+
|
|
190
|
+
# Step 1 — apply filter
|
|
191
|
+
if peers_filter:
|
|
192
|
+
wanted = [s.strip() for s in peers_filter.split(",") if s.strip()]
|
|
193
|
+
filtered = [p for p in all_peers if p.get("id") in wanted]
|
|
194
|
+
elif peer_filter:
|
|
195
|
+
filtered = [p for p in all_peers if p.get("id") == peer_filter]
|
|
196
|
+
else:
|
|
197
|
+
filtered = list(all_peers)
|
|
198
|
+
|
|
199
|
+
# Step 2 — alpha exclusion
|
|
200
|
+
def _exclude_alpha(peers: list[dict]) -> list[dict]:
|
|
201
|
+
if not alpha_cli:
|
|
202
|
+
return peers
|
|
203
|
+
return [p for p in peers if p.get("cli") != alpha_cli]
|
|
204
|
+
|
|
205
|
+
result = _exclude_alpha(filtered)
|
|
206
|
+
|
|
207
|
+
# Step 3 — fallback to "all peers except alpha" if filter missed
|
|
208
|
+
if not result and (peer_filter or peers_filter):
|
|
209
|
+
result = _exclude_alpha(all_peers)
|
|
210
|
+
|
|
211
|
+
# Step 4 — refresh detection (maybe user installed something since last handshake)
|
|
212
|
+
if not result:
|
|
213
|
+
proposed = propose_roster()
|
|
214
|
+
if proposed:
|
|
215
|
+
fresh_dicts = proposed_as_yaml_dicts(proposed)
|
|
216
|
+
known_ids = {p.get("id") for p in all_peers}
|
|
217
|
+
added = [p for p in fresh_dicts if p.get("id") not in known_ids]
|
|
218
|
+
if added:
|
|
219
|
+
write_peers(state, all_peers + added)
|
|
220
|
+
ensure_peer_dirs(state, proposed)
|
|
221
|
+
all_peers = all_peers + added
|
|
222
|
+
result = _exclude_alpha(all_peers)
|
|
223
|
+
|
|
224
|
+
# Step 5 — last resort: include alpha-cli peer if that's all we have
|
|
225
|
+
if not result:
|
|
226
|
+
result = list(all_peers)
|
|
227
|
+
|
|
228
|
+
for p in result:
|
|
229
|
+
pid = p.get("id")
|
|
230
|
+
if pid:
|
|
231
|
+
click.echo(pid)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# Invoke — fire one peer
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
@main.command()
|
|
239
|
+
@click.argument("stage")
|
|
240
|
+
def converge(stage: str) -> None:
|
|
241
|
+
"""Converge a stage — copy each participant's latest `vN.md` to `{id}-FINAL.md`.
|
|
242
|
+
|
|
243
|
+
Runs on the active focus. Mechanical file-copy only; the slash command
|
|
244
|
+
writes `consensus.md` afterward from the FINAL files.
|
|
245
|
+
"""
|
|
246
|
+
from paircode.seal import seal_stage
|
|
247
|
+
|
|
248
|
+
state = find_paircode()
|
|
249
|
+
if state is None or state.active_focus is None:
|
|
250
|
+
raise click.ClickException(
|
|
251
|
+
"No active focus. Run `paircode focus new <slug>` first."
|
|
252
|
+
)
|
|
253
|
+
stage_dir = state.active_focus / stage
|
|
254
|
+
if not stage_dir.exists():
|
|
255
|
+
raise click.ClickException(f"Stage dir not found: {stage_dir}")
|
|
256
|
+
sealed = seal_stage(stage_dir)
|
|
257
|
+
if not sealed:
|
|
258
|
+
raise click.ClickException(
|
|
259
|
+
f"No versioned files found in {stage_dir} — nothing to converge."
|
|
260
|
+
)
|
|
261
|
+
for s in sealed:
|
|
262
|
+
click.echo(f"{s.peer_id} → {s.final.name}")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@main.command()
|
|
266
|
+
@click.argument("peer_id")
|
|
267
|
+
@click.argument("prompt")
|
|
268
|
+
@click.option("--out", "out_path", required=True, type=click.Path(),
|
|
269
|
+
help="Where to write the peer's file-trace markdown")
|
|
270
|
+
@click.option("--timeout", default=600, show_default=True,
|
|
271
|
+
help="Per-peer timeout in seconds")
|
|
272
|
+
@click.option("--fast/--no-fast", default=False,
|
|
273
|
+
help="Apply cliworker speed flags (faster, fewer tools)")
|
|
274
|
+
def invoke(peer_id: str, prompt: str, out_path: str, timeout: int, fast: bool) -> None:
|
|
275
|
+
"""Run one peer LLM against PROMPT; write file-trace markdown to --out.
|
|
276
|
+
|
|
277
|
+
PEER_ID must match an `id` in .paircode/peers.yaml.
|
|
278
|
+
Exits 0 on peer success, non-zero on failure. Always writes a file-trace
|
|
279
|
+
(even on failure) so every invocation leaves evidence on disk.
|
|
280
|
+
"""
|
|
281
|
+
state = find_paircode()
|
|
282
|
+
if state is None:
|
|
283
|
+
raise click.ClickException(
|
|
284
|
+
"No .paircode/ found. Run `paircode ensure-scaffold` first."
|
|
285
|
+
)
|
|
286
|
+
peers = read_peers(state)
|
|
287
|
+
peer = next((p for p in peers if p.get("id") == peer_id), None)
|
|
288
|
+
if peer is None:
|
|
289
|
+
known = [p.get("id") for p in peers]
|
|
290
|
+
raise click.ClickException(
|
|
291
|
+
f"Unknown peer id: {peer_id!r}. Known: {known}"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
result = run_peer(
|
|
295
|
+
peer_id=peer_id,
|
|
296
|
+
cli=str(peer.get("cli")),
|
|
297
|
+
prompt=prompt,
|
|
298
|
+
output_path=Path(out_path),
|
|
299
|
+
model=peer.get("model"),
|
|
300
|
+
timeout_s=timeout,
|
|
301
|
+
fast=fast,
|
|
302
|
+
)
|
|
303
|
+
status = "ok" if result.ok else "FAIL"
|
|
304
|
+
click.echo(f"{peer_id} {status} {result.duration_s:.1f}s", err=True)
|
|
305
|
+
if not result.ok:
|
|
306
|
+
raise SystemExit(1)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
# Bare paircode — show state
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
def _show_state() -> None:
|
|
314
|
+
"""Bare `paircode` prints current .paircode/ state. Former `status` output."""
|
|
315
|
+
state = find_paircode()
|
|
316
|
+
console.print(f"[bold]paircode[/bold] v{__version__}")
|
|
317
|
+
if state is None:
|
|
318
|
+
console.print(
|
|
319
|
+
"\n[yellow]No .paircode/ found in cwd or any parent.[/yellow]\n"
|
|
320
|
+
"Run [cyan]paircode install[/cyan] to register /paircode in your LLM CLIs,\n"
|
|
321
|
+
"then run [cyan]/paircode \"your prompt\"[/cyan] inside Claude / Codex / Gemini\n"
|
|
322
|
+
"to kick off a peer-review cycle."
|
|
323
|
+
)
|
|
324
|
+
return
|
|
325
|
+
console.print(f"\n[bold].paircode/[/bold] at [cyan]{state.root}[/cyan]")
|
|
326
|
+
console.print(f" project root: {state.project_root}")
|
|
327
|
+
console.print(f" focuses: {state.focus_count}")
|
|
328
|
+
if state.active_focus:
|
|
329
|
+
console.print(f" active focus: [green]{state.active_focus.name}[/green]")
|
|
330
|
+
peers = read_peers(state)
|
|
331
|
+
console.print(f" peers: {len(peers)} configured")
|
|
332
|
+
if peers:
|
|
333
|
+
ptable = Table(show_header=True, header_style="bold")
|
|
334
|
+
ptable.add_column("id")
|
|
335
|
+
ptable.add_column("cli")
|
|
336
|
+
ptable.add_column("priority")
|
|
337
|
+
for p in peers:
|
|
338
|
+
ptable.add_row(
|
|
339
|
+
str(p.get("id", "?")),
|
|
340
|
+
str(p.get("cli", "?")),
|
|
341
|
+
str(p.get("priority", "?")),
|
|
342
|
+
)
|
|
343
|
+
console.print(ptable)
|
|
344
|
+
if state.focus_dirs:
|
|
345
|
+
ftable = Table(show_header=True, header_style="bold")
|
|
346
|
+
ftable.add_column("#")
|
|
347
|
+
ftable.add_column("focus")
|
|
348
|
+
for i, d in enumerate(state.focus_dirs, 1):
|
|
349
|
+
ftable.add_row(str(i), d.name)
|
|
350
|
+
console.print(ftable)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
if __name__ == "__main__":
|
|
354
|
+
main()
|