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.
Files changed (39) hide show
  1. {paircode-0.10.1 → paircode-0.11.1}/PKG-INFO +1 -1
  2. paircode-0.11.1/diary/002-v0.10-release-pipeline.md +126 -0
  3. paircode-0.11.1/diary/003-arch-b-pivot-grappling.md +133 -0
  4. {paircode-0.10.1 → paircode-0.11.1}/pyproject.toml +1 -1
  5. {paircode-0.10.1 → paircode-0.11.1}/src/paircode/__init__.py +1 -1
  6. paircode-0.11.1/src/paircode/cli.py +354 -0
  7. {paircode-0.10.1 → paircode-0.11.1}/src/paircode/handshake.py +6 -8
  8. {paircode-0.10.1 → paircode-0.11.1}/src/paircode/installer.py +43 -18
  9. {paircode-0.10.1 → paircode-0.11.1}/src/paircode/state.py +61 -7
  10. paircode-0.11.1/src/paircode/templates/claude/commands/paircode.md +169 -0
  11. paircode-0.11.1/src/paircode/templates/codex/commands/paircode.md +161 -0
  12. paircode-0.11.1/src/paircode/templates/gemini/commands/paircode.toml +152 -0
  13. {paircode-0.10.1 → paircode-0.11.1}/src/paircode/templates/peers.yaml +0 -1
  14. paircode-0.11.1/tests/test_cli_smoke.py +668 -0
  15. {paircode-0.10.1 → paircode-0.11.1}/tests/test_smoke.py +4 -18
  16. {paircode-0.10.1 → paircode-0.11.1}/tests/test_state.py +40 -1
  17. paircode-0.10.1/src/paircode/cli.py +0 -419
  18. paircode-0.10.1/src/paircode/drive.py +0 -385
  19. paircode-0.10.1/src/paircode/gates.py +0 -62
  20. paircode-0.10.1/src/paircode/templates/claude_slash_command.md +0 -23
  21. paircode-0.10.1/src/paircode/templates/codex_rules.md +0 -13
  22. paircode-0.10.1/src/paircode/templates/codex_slash_command.md +0 -24
  23. paircode-0.10.1/src/paircode/templates/gemini_slash_command.toml +0 -25
  24. paircode-0.10.1/tests/test_cli_smoke.py +0 -377
  25. paircode-0.10.1/tests/test_drive.py +0 -146
  26. paircode-0.10.1/tests/test_gates.py +0 -49
  27. {paircode-0.10.1 → paircode-0.11.1}/.gitignore +0 -0
  28. {paircode-0.10.1 → paircode-0.11.1}/LICENSE +0 -0
  29. {paircode-0.10.1 → paircode-0.11.1}/README.md +0 -0
  30. {paircode-0.10.1 → paircode-0.11.1}/diary/001-step-a-architecture.md +0 -0
  31. {paircode-0.10.1 → paircode-0.11.1}/src/paircode/__main__.py +0 -0
  32. {paircode-0.10.1 → paircode-0.11.1}/src/paircode/detect.py +0 -0
  33. {paircode-0.10.1 → paircode-0.11.1}/src/paircode/journey.py +0 -0
  34. {paircode-0.10.1 → paircode-0.11.1}/src/paircode/runner.py +0 -0
  35. {paircode-0.10.1 → paircode-0.11.1}/src/paircode/seal.py +0 -0
  36. {paircode-0.10.1 → paircode-0.11.1}/src/paircode/templates/FOCUS.md +0 -0
  37. {paircode-0.10.1 → paircode-0.11.1}/src/paircode/templates/JOURNEY.md +0 -0
  38. {paircode-0.10.1 → paircode-0.11.1}/tests/__init__.py +0 -0
  39. {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.10.1
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.10.1"
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" }
@@ -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.10.0"
5
+ __version__ = "0.11.0"
@@ -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()