paircode 0.7.1__tar.gz → 0.9.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 (34) hide show
  1. {paircode-0.7.1 → paircode-0.9.1}/PKG-INFO +20 -6
  2. {paircode-0.7.1 → paircode-0.9.1}/README.md +15 -2
  3. {paircode-0.7.1 → paircode-0.9.1}/pyproject.toml +5 -4
  4. {paircode-0.7.1 → paircode-0.9.1}/src/paircode/__init__.py +1 -1
  5. paircode-0.9.1/src/paircode/installer.py +188 -0
  6. paircode-0.9.1/src/paircode/runner.py +122 -0
  7. paircode-0.9.1/src/paircode/templates/codex_slash_command.md +24 -0
  8. paircode-0.9.1/src/paircode/templates/gemini_slash_command.toml +25 -0
  9. paircode-0.9.1/tests/test_cli_smoke.py +280 -0
  10. {paircode-0.7.1 → paircode-0.9.1}/tests/test_smoke.py +59 -3
  11. paircode-0.7.1/src/paircode/installer.py +0 -161
  12. paircode-0.7.1/src/paircode/runner.py +0 -114
  13. {paircode-0.7.1 → paircode-0.9.1}/.gitignore +0 -0
  14. {paircode-0.7.1 → paircode-0.9.1}/LICENSE +0 -0
  15. {paircode-0.7.1 → paircode-0.9.1}/diary/001-step-a-architecture.md +0 -0
  16. {paircode-0.7.1 → paircode-0.9.1}/src/paircode/__main__.py +0 -0
  17. {paircode-0.7.1 → paircode-0.9.1}/src/paircode/cli.py +0 -0
  18. {paircode-0.7.1 → paircode-0.9.1}/src/paircode/detect.py +0 -0
  19. {paircode-0.7.1 → paircode-0.9.1}/src/paircode/drive.py +0 -0
  20. {paircode-0.7.1 → paircode-0.9.1}/src/paircode/gates.py +0 -0
  21. {paircode-0.7.1 → paircode-0.9.1}/src/paircode/handshake.py +0 -0
  22. {paircode-0.7.1 → paircode-0.9.1}/src/paircode/journey.py +0 -0
  23. {paircode-0.7.1 → paircode-0.9.1}/src/paircode/seal.py +0 -0
  24. {paircode-0.7.1 → paircode-0.9.1}/src/paircode/state.py +0 -0
  25. {paircode-0.7.1 → paircode-0.9.1}/src/paircode/templates/FOCUS.md +0 -0
  26. {paircode-0.7.1 → paircode-0.9.1}/src/paircode/templates/JOURNEY.md +0 -0
  27. {paircode-0.7.1 → paircode-0.9.1}/src/paircode/templates/claude_slash_command.md +0 -0
  28. {paircode-0.7.1 → paircode-0.9.1}/src/paircode/templates/codex_rules.md +0 -0
  29. {paircode-0.7.1 → paircode-0.9.1}/src/paircode/templates/peers.yaml +0 -0
  30. {paircode-0.7.1 → paircode-0.9.1}/tests/__init__.py +0 -0
  31. {paircode-0.7.1 → paircode-0.9.1}/tests/test_drive.py +0 -0
  32. {paircode-0.7.1 → paircode-0.9.1}/tests/test_gates.py +0 -0
  33. {paircode-0.7.1 → paircode-0.9.1}/tests/test_seal.py +0 -0
  34. {paircode-0.7.1 → paircode-0.9.1}/tests/test_state.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paircode
3
- Version: 0.7.1
3
+ Version: 0.9.1
4
4
  Summary: Adversarial journey framework — orchestrate multiple LLM peers through research, plan, and execute stages with file-trace peer review.
5
- Project-URL: Homepage, https://github.com/tsomerville2/paircode
6
- Project-URL: Repository, https://github.com/tsomerville2/paircode
7
- Project-URL: Issues, https://github.com/tsomerville2/paircode/issues
5
+ Project-URL: Homepage, https://github.com/starshipagentic/paircode
6
+ Project-URL: Repository, https://github.com/starshipagentic/paircode
7
+ Project-URL: Issues, https://github.com/starshipagentic/paircode/issues
8
8
  Author-email: Travis Somerville <travis.somerville@gmail.com>
9
9
  License: MIT
10
10
  License-File: LICENSE
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Topic :: Software Development
22
22
  Requires-Python: >=3.10
23
23
  Requires-Dist: click>=8.1
24
+ Requires-Dist: cliworker>=0.7.1
24
25
  Requires-Dist: pyyaml>=6.0
25
26
  Requires-Dist: rich>=13.0
26
27
  Description-Content-Type: text/markdown
@@ -35,10 +36,23 @@ Description-Content-Type: text/markdown
35
36
 
36
37
  ```bash
37
38
  pipx install paircode # or: pip install --user paircode
38
- paircode install # registers /paircode in Claude / Codex / Gemini
39
+ paircode install # registers /paircode in every detected LLM CLI
39
40
  ```
40
41
 
41
- Then open Claude Code, Codex, or Gemini and you'll see `/paircode` available globally.
42
+ After install, `/paircode` is available in all three:
43
+
44
+ | CLI | File installed |
45
+ |---|---|
46
+ | Claude Code | `~/.claude/commands/paircode.md` |
47
+ | Codex CLI | `~/.codex/prompts/paircode.md` |
48
+ | Gemini CLI | `~/.gemini/commands/paircode.toml` |
49
+
50
+ Open any of them and type `/paircode`. In Gemini, you may need `/commands reload` the first time.
51
+
52
+ As of v0.8.0, paircode delegates all CLI invocation to [`cliworker`](https://pypi.org/project/cliworker/) — one place to own
53
+ the speed flags, MCP strip tricks, skip-cache, and subscription-first fallback
54
+ logic. paircode adds the peer-review orchestration on top (file-traces, stages,
55
+ gates, journey).
42
56
 
43
57
  ## Use it — three entry points
44
58
 
@@ -8,10 +8,23 @@
8
8
 
9
9
  ```bash
10
10
  pipx install paircode # or: pip install --user paircode
11
- paircode install # registers /paircode in Claude / Codex / Gemini
11
+ paircode install # registers /paircode in every detected LLM CLI
12
12
  ```
13
13
 
14
- Then open Claude Code, Codex, or Gemini and you'll see `/paircode` available globally.
14
+ After install, `/paircode` is available in all three:
15
+
16
+ | CLI | File installed |
17
+ |---|---|
18
+ | Claude Code | `~/.claude/commands/paircode.md` |
19
+ | Codex CLI | `~/.codex/prompts/paircode.md` |
20
+ | Gemini CLI | `~/.gemini/commands/paircode.toml` |
21
+
22
+ Open any of them and type `/paircode`. In Gemini, you may need `/commands reload` the first time.
23
+
24
+ As of v0.8.0, paircode delegates all CLI invocation to [`cliworker`](https://pypi.org/project/cliworker/) — one place to own
25
+ the speed flags, MCP strip tricks, skip-cache, and subscription-first fallback
26
+ logic. paircode adds the peer-review orchestration on top (file-traces, stages,
27
+ gates, journey).
15
28
 
16
29
  ## Use it — three entry points
17
30
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "paircode"
7
- version = "0.7.1"
7
+ version = "0.9.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" }
@@ -39,12 +39,13 @@ dependencies = [
39
39
  "click>=8.1",
40
40
  "pyyaml>=6.0",
41
41
  "rich>=13.0",
42
+ "cliworker>=0.7.1",
42
43
  ]
43
44
 
44
45
  [project.urls]
45
- Homepage = "https://github.com/tsomerville2/paircode"
46
- Repository = "https://github.com/tsomerville2/paircode"
47
- Issues = "https://github.com/tsomerville2/paircode/issues"
46
+ Homepage = "https://github.com/starshipagentic/paircode"
47
+ Repository = "https://github.com/starshipagentic/paircode"
48
+ Issues = "https://github.com/starshipagentic/paircode/issues"
48
49
 
49
50
  [project.scripts]
50
51
  paircode = "paircode.cli:main"
@@ -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.7.0"
5
+ __version__ = "0.9.0"
@@ -0,0 +1,188 @@
1
+ """Install paircode's slash command into detected LLM CLIs.
2
+
3
+ Strategy per CLI (all file-drop; see v1.0 roadmap for native register migration):
4
+ - Claude Code: ~/.claude/commands/paircode.md (Markdown + YAML frontmatter).
5
+ First-class documented path.
6
+ - Codex CLI: ~/.codex/prompts/paircode.md (Markdown + YAML frontmatter).
7
+ Path marked "deprecated" by OpenAI (they're steering toward
8
+ marketplaces), but still loads and remains the only file-drop
9
+ path for user-typed slash commands in codex.
10
+ - Gemini CLI: ~/.gemini/commands/paircode.toml (TOML with description + prompt).
11
+ First-class, first-party format. Not Markdown.
12
+
13
+ Also cleans up legacy broken files from paircode 0.1–0.7 which wrote
14
+ ~/.codex/rules/paircode.rules (that file is Starlark-parsed and our markdown
15
+ broke codex's rule loader).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from importlib import resources
21
+ from pathlib import Path
22
+
23
+ from paircode.detect import detect_all, CliInfo
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class InstallResult:
28
+ cli_name: str
29
+ action: str # "installed", "skipped", "failed", "noop"
30
+ path: Path | None
31
+ message: str
32
+
33
+
34
+ def _read_template(name: str) -> str:
35
+ return resources.files("paircode.templates").joinpath(name).read_text(encoding="utf-8")
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Per-CLI installers
40
+ # ---------------------------------------------------------------------------
41
+
42
+ def install_claude(info: CliInfo) -> InstallResult:
43
+ """Write ~/.claude/commands/paircode.md — a real Claude Code slash command."""
44
+ if not info.installed:
45
+ return InstallResult(
46
+ cli_name="claude", action="skipped", path=None,
47
+ message=f"claude CLI not on PATH. {info.install_hint}",
48
+ )
49
+ commands_dir = info.config_dir / "commands"
50
+ commands_dir.mkdir(parents=True, exist_ok=True)
51
+ target = commands_dir / "paircode.md"
52
+ target.write_text(_read_template("claude_slash_command.md"), encoding="utf-8")
53
+ return InstallResult(
54
+ cli_name="claude", action="installed", path=target,
55
+ message=f"Wrote /paircode slash command to {target}.",
56
+ )
57
+
58
+
59
+ def install_codex(info: CliInfo) -> InstallResult:
60
+ """Write ~/.codex/prompts/paircode.md — codex's custom-prompt slash command path.
61
+
62
+ Codex flags this path as "deprecated" in favor of marketplace plugins, but
63
+ it's still the only file-drop path for user-typed slash commands. When
64
+ codex removes this (they haven't yet), paircode will migrate to
65
+ `codex marketplace add` with a satellite repo.
66
+ """
67
+ if not info.installed:
68
+ return InstallResult(
69
+ cli_name="codex", action="skipped", path=None,
70
+ message=f"codex CLI not on PATH. {info.install_hint}",
71
+ )
72
+ prompts_dir = info.config_dir / "prompts"
73
+ prompts_dir.mkdir(parents=True, exist_ok=True)
74
+ target = prompts_dir / "paircode.md"
75
+ target.write_text(_read_template("codex_slash_command.md"), encoding="utf-8")
76
+ return InstallResult(
77
+ cli_name="codex", action="installed", path=target,
78
+ message=f"Wrote /paircode slash command to {target}. Inside codex interactive, /paircode will appear.",
79
+ )
80
+
81
+
82
+ def install_gemini(info: CliInfo) -> InstallResult:
83
+ """Write ~/.gemini/commands/paircode.toml — gemini's first-class custom command path.
84
+
85
+ Gemini uses TOML (not Markdown) with `description` and `prompt` fields.
86
+ After install, run `/commands reload` inside gemini or restart the session.
87
+ """
88
+ if not info.installed:
89
+ return InstallResult(
90
+ cli_name="gemini", action="skipped", path=None,
91
+ message=f"gemini CLI not on PATH. {info.install_hint}",
92
+ )
93
+ commands_dir = info.config_dir / "commands"
94
+ commands_dir.mkdir(parents=True, exist_ok=True)
95
+ target = commands_dir / "paircode.toml"
96
+ target.write_text(_read_template("gemini_slash_command.toml"), encoding="utf-8")
97
+ return InstallResult(
98
+ cli_name="gemini", action="installed", path=target,
99
+ message=(
100
+ f"Wrote /paircode slash command to {target}. "
101
+ "Run `/commands reload` inside gemini (or restart) to pick it up."
102
+ ),
103
+ )
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Public entry points
108
+ # ---------------------------------------------------------------------------
109
+
110
+ def install_all() -> list[InstallResult]:
111
+ """Run every CLI's installer. Also cleans up legacy broken files first."""
112
+ _clean_legacy_broken_files()
113
+ detected = detect_all()
114
+ installers = {
115
+ "claude": install_claude,
116
+ "codex": install_codex,
117
+ "gemini": install_gemini,
118
+ }
119
+ results: list[InstallResult] = []
120
+ for name, info in detected.items():
121
+ installer = installers.get(name)
122
+ if not installer:
123
+ continue
124
+ try:
125
+ results.append(installer(info))
126
+ except Exception as exc: # pragma: no cover — defensive
127
+ results.append(InstallResult(
128
+ cli_name=name, action="failed", path=None,
129
+ message=f"{type(exc).__name__}: {exc}",
130
+ ))
131
+ return results
132
+
133
+
134
+ def _clean_legacy_broken_files() -> None:
135
+ """Remove files prior paircode versions installed and which we now know
136
+ to be broken or misplaced. Silent best-effort."""
137
+ # v0.1–v0.7 wrote markdown to codex's Starlark rules dir, which broke
138
+ # codex's rule loader on every session startup.
139
+ legacy = Path.home() / ".codex" / "rules" / "paircode.rules"
140
+ try:
141
+ if legacy.exists():
142
+ legacy.unlink()
143
+ except OSError:
144
+ pass
145
+
146
+ # v0.1–v0.7 also dropped a reference file at ~/.gemini/paircode.md.
147
+ # v0.9+ writes the real slash command at ~/.gemini/commands/paircode.toml
148
+ # instead; clean up the old reference.
149
+ legacy_gemini = Path.home() / ".gemini" / "paircode.md"
150
+ try:
151
+ if legacy_gemini.exists():
152
+ legacy_gemini.unlink()
153
+ except OSError:
154
+ pass
155
+
156
+
157
+ def uninstall_all() -> list[InstallResult]:
158
+ """Remove paircode entries from every CLI config dir (idempotent).
159
+ Also cleans up legacy broken paths from prior paircode versions."""
160
+ results: list[InstallResult] = []
161
+ paths: list[tuple[Path, str]] = [
162
+ # Current v0.9+ paths
163
+ (Path.home() / ".claude" / "commands" / "paircode.md", "claude"),
164
+ (Path.home() / ".codex" / "prompts" / "paircode.md", "codex"),
165
+ (Path.home() / ".gemini" / "commands" / "paircode.toml", "gemini"),
166
+ # Legacy paths from older paircode versions
167
+ (Path.home() / ".codex" / "rules" / "paircode.rules", "codex-legacy"),
168
+ (Path.home() / ".gemini" / "paircode.md", "gemini-legacy"),
169
+ ]
170
+ for path, cli_name in paths:
171
+ if path.exists():
172
+ try:
173
+ path.unlink()
174
+ results.append(InstallResult(
175
+ cli_name=cli_name, action="installed", # "removed"
176
+ path=path, message=f"Removed {path}",
177
+ ))
178
+ except OSError as exc:
179
+ results.append(InstallResult(
180
+ cli_name=cli_name, action="failed",
181
+ path=path, message=f"Failed to remove {path}: {exc}",
182
+ ))
183
+ else:
184
+ results.append(InstallResult(
185
+ cli_name=cli_name, action="skipped",
186
+ path=path, message=f"Nothing to remove at {path}",
187
+ ))
188
+ return results
@@ -0,0 +1,122 @@
1
+ """Invoke peer LLM CLIs via cliworker, capture output to .md files.
2
+
3
+ Thin wrapper over `cliworker.run()`. Gives paircode:
4
+ * CLAUDE_FAST flags when fast=True (claude -p goes from 18s → 4s)
5
+ * Gemini MCP strip-and-restore during calls
6
+ * Skip-cache for broken engines (1h TTL)
7
+ * Subscription-mode-first by default (no API credits unless paid_ok=True)
8
+ * One place to fix CLI quirks instead of re-implementing them here
9
+
10
+ Adds one paircode-specific thing cliworker doesn't: writing each peer's
11
+ response to a .md file with a file-trace header (peer_id / cli / model /
12
+ duration / ok). That's the orchestration log paircode needs.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ from cliworker import CLIResult, get_spec, run
21
+
22
+
23
+ DEFAULT_TIMEOUT_SECONDS = 600 # 10 min per peer call — generous for cold research
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class PeerRunResult:
28
+ """paircode's peer-run result — wraps cliworker's CLIResult with a peer_id."""
29
+ peer_id: str
30
+ cli: str
31
+ ok: bool
32
+ stdout: str
33
+ stderr: str
34
+ duration_s: float
35
+ command: list[str]
36
+
37
+ @classmethod
38
+ def from_cli_result(cls, peer_id: str, r: CLIResult) -> "PeerRunResult":
39
+ return cls(
40
+ peer_id=peer_id,
41
+ cli=r.spec.cli,
42
+ ok=r.ok,
43
+ stdout=r.stdout,
44
+ stderr=r.stderr,
45
+ duration_s=r.duration_s,
46
+ command=list(r.argv),
47
+ )
48
+
49
+
50
+ def run_peer(
51
+ peer_id: str,
52
+ cli: str,
53
+ prompt: str,
54
+ output_path: Path,
55
+ model: Optional[str] = None,
56
+ timeout_s: int = DEFAULT_TIMEOUT_SECONDS,
57
+ fast: bool = False,
58
+ paid_ok: bool | list[str] | None = None,
59
+ ) -> PeerRunResult:
60
+ """Run one peer LLM against `prompt`, write its stdout to `output_path`.
61
+
62
+ Delegates to `cliworker.run()` for the actual subprocess call. Adds the
63
+ paircode-specific file-trace header. Output file is written regardless
64
+ of success — every invocation leaves a trace on disk.
65
+
66
+ Args:
67
+ peer_id: paircode-level identifier (e.g. "peer-a-codex"). Not known to cliworker.
68
+ cli: one of "claude", "codex", "gemini", "ollama".
69
+ prompt: the instruction to send.
70
+ output_path: where to write the file-trace .md.
71
+ model: optional model override (e.g. "sonnet", "gemma3:4b").
72
+ timeout_s: per-call timeout.
73
+ fast: True = apply cliworker's speed flags (CLAUDE_FAST, gemini MCP strip).
74
+ False (default) = full mode — all MCPs/tools loaded, matches how
75
+ the user's normal `claude -p` session behaves.
76
+ paid_ok: None (default) = free/subscription only, never burn API credits.
77
+ True = allow paid API fallback for this CLI.
78
+ list[str] = allow paid only for those CLI names (typically just `cli`).
79
+ """
80
+ # Build spec with optional model override
81
+ spec = get_spec(cli, model=model) if model else get_spec(cli)
82
+
83
+ # cliworker's run() needs None (not False) to "respect spec default" for fast.
84
+ results = run(
85
+ prompt, spec,
86
+ fast=True if fast else None,
87
+ paid_ok=paid_ok,
88
+ timeout_s=timeout_s,
89
+ )
90
+
91
+ # cliworker.run() returns empty list only if no CLIs passed AND no default chain.
92
+ # We passed an explicit spec, so this should always have at least one result.
93
+ if results:
94
+ cli_result = results[-1]
95
+ else:
96
+ # Defensive fallback — construct a failure result
97
+ cli_result = CLIResult(
98
+ spec=spec, ok=False, stdout="",
99
+ stderr=f"no result from cliworker for {cli}",
100
+ duration_s=0.0, returncode=None, argv=[], skipped_reason=None,
101
+ )
102
+
103
+ # Write the paircode file-trace
104
+ output_path.parent.mkdir(parents=True, exist_ok=True)
105
+ header = (
106
+ f"<!-- peer_id: {peer_id} -->\n"
107
+ f"<!-- cli: {cli} -->\n"
108
+ f"<!-- model: {model or '(default)'} -->\n"
109
+ f"<!-- duration_s: {cli_result.duration_s:.1f} -->\n"
110
+ f"<!-- ok: {cli_result.ok} -->\n\n"
111
+ )
112
+ if cli_result.ok:
113
+ body = cli_result.stdout
114
+ else:
115
+ body = (
116
+ f"# Peer run FAILED\n\n"
117
+ f"```\n{cli_result.stderr}\n```\n\n"
118
+ f"{cli_result.stdout}"
119
+ )
120
+ output_path.write_text(header + body, encoding="utf-8")
121
+
122
+ return PeerRunResult.from_cli_result(peer_id, cli_result)
@@ -0,0 +1,24 @@
1
+ ---
2
+ description: Run paircode — adversarial journey framework for LLM peer review. Bootstraps .paircode/ if missing, otherwise advances the active focus.
3
+ argument-hint: [SUBCOMMAND or TOPIC]
4
+ ---
5
+
6
+ You have access to the `paircode` CLI. It's a Python tool that orchestrates multi-LLM peer review (research → plan → execute) with file-traces on disk.
7
+
8
+ Behavior:
9
+
10
+ 1. **Parse the arguments.** Everything after `/paircode` is passed as CLI args. If empty, run `paircode` with no args (it prints status or bootstraps).
11
+ 2. **Run the CLI.** Invoke `paircode <args>` via shell. Capture stdout and stderr.
12
+ 3. **Relay output to the user.** Show what paircode printed.
13
+ 4. **If `paircode` spawns its own LLM subprocesses** (for research/plan/execute stages), it handles that internally — just relay status updates.
14
+ 5. **If the user's intent was a topic-level drive** (e.g., `/paircode "build a widget thing"`), invoke `paircode drive "<topic>"`.
15
+
16
+ Common subcommands:
17
+ - `paircode install` — register /paircode in Claude/Codex/Gemini
18
+ - `paircode status` — summarize .paircode/ state in cwd
19
+ - `paircode handshake` — detect installed LLM CLIs, propose peer roster
20
+ - `paircode focus <name>` — open a new focus
21
+ - `paircode stage {research|plan|execute}` — run one peer-review round
22
+ - `paircode drive "<topic>"` — high-level: open focus, run research → plan → execute
23
+
24
+ Always show the user what happened. Brief, honest, actionable.
@@ -0,0 +1,25 @@
1
+ description = "Run paircode — adversarial journey framework for LLM peer review. Bootstraps .paircode/ if missing, otherwise advances the active focus."
2
+
3
+ prompt = """
4
+ You have access to the `paircode` CLI — a Python tool that orchestrates multi-LLM peer review (research → plan → execute) with file-traces on disk.
5
+
6
+ Arguments: {{args}}
7
+
8
+ Behavior:
9
+
10
+ 1. Parse the arguments above. If empty, run `paircode` with no args (it prints status or bootstraps).
11
+ 2. Run the CLI: invoke `paircode {{args}}` via shell. Capture stdout and stderr.
12
+ 3. Relay output to the user. Show what paircode printed.
13
+ 4. If `paircode` spawns its own LLM subprocesses (for research/plan/execute stages), it handles that internally — just relay status updates.
14
+ 5. If the user's intent was a topic-level drive (e.g. `/paircode "build a widget thing"`), invoke `paircode drive "<topic>"`.
15
+
16
+ Common subcommands:
17
+ paircode install register /paircode in Claude/Codex/Gemini
18
+ paircode status summarize .paircode/ state in cwd
19
+ paircode handshake detect installed LLM CLIs, propose peer roster
20
+ paircode focus <name> open a new focus
21
+ paircode stage {research|plan|execute} run one peer-review round
22
+ paircode drive "<topic>" high-level: run research → plan → execute
23
+
24
+ Always show the user what happened. Brief, honest, actionable.
25
+ """
@@ -0,0 +1,280 @@
1
+ """End-to-end CLI smoke tests for paircode (v0.8+).
2
+
3
+ Invokes the real CLI via CliRunner and asserts on real output. Catches
4
+ regressions unit tests miss: broken subcommands, click routing drift,
5
+ wrong default behavior, missing help content.
6
+
7
+ Mirrors the same approach we added to cliworker in its v0.5.5.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+ from click.testing import CliRunner
15
+
16
+ from paircode import __version__
17
+ from paircode.cli import main
18
+
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Help-lint: every registered command + every --help variant must work.
22
+ # ---------------------------------------------------------------------------
23
+
24
+ @pytest.mark.parametrize(
25
+ "cmd_argv",
26
+ [
27
+ ["--help"],
28
+ ["--version"],
29
+ ["install", "--help"],
30
+ ["uninstall", "--help"],
31
+ ["handshake", "--help"],
32
+ ["status", "--help"],
33
+ ["init", "--help"],
34
+ ["focus", "--help"],
35
+ ["stage", "--help"],
36
+ ["seal", "--help"],
37
+ ["drive", "--help"],
38
+ ],
39
+ )
40
+ def test_every_command_help_is_reachable(cmd_argv):
41
+ runner = CliRunner()
42
+ result = runner.invoke(main, cmd_argv)
43
+ assert result.exit_code == 0, (
44
+ f"{cmd_argv} exited {result.exit_code}:\n{result.output}"
45
+ )
46
+ assert result.output.strip(), f"{cmd_argv} printed nothing"
47
+
48
+
49
+ def test_main_help_lists_all_public_subcommands():
50
+ runner = CliRunner()
51
+ result = runner.invoke(main, ["--help"])
52
+ assert result.exit_code == 0
53
+ for cmd in (
54
+ "install", "uninstall", "handshake", "status",
55
+ "init", "focus", "stage", "seal", "drive",
56
+ ):
57
+ assert cmd in result.output, f"Main --help missing subcommand: {cmd}"
58
+
59
+
60
+ def test_version_string_matches_dunder():
61
+ runner = CliRunner()
62
+ result = runner.invoke(main, ["--version"])
63
+ assert result.exit_code == 0
64
+ assert __version__ in result.output
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Runner <-> cliworker integration
69
+ # ---------------------------------------------------------------------------
70
+
71
+ def test_runner_imports_cliworker():
72
+ """Regression: paircode.runner must import and delegate to cliworker as
73
+ of v0.8+. If someone reverts the refactor, this test breaks."""
74
+ import paircode.runner as runner
75
+ import cliworker
76
+
77
+ # The run_peer function should internally call cliworker.run
78
+ source = Path(runner.__file__).read_text()
79
+ assert "from cliworker" in source, (
80
+ "paircode.runner must use cliworker — "
81
+ "the whole point of v0.8 was to delegate to it"
82
+ )
83
+ assert "cliworker.run" in source or "import run" in source
84
+
85
+
86
+ def test_run_peer_wraps_cliworker_result(monkeypatch, tmp_path):
87
+ """run_peer should call cliworker.run() and return a PeerRunResult
88
+ with the peer_id set + the file-trace written to disk."""
89
+ from paircode.runner import run_peer, PeerRunResult
90
+
91
+ # Intercept cliworker.run so no real subprocess fires
92
+ from cliworker.core import CLIResult
93
+ from cliworker import get_spec
94
+
95
+ def fake_run(prompt, *specs, **kwargs):
96
+ spec = specs[0] if specs else get_spec("claude")
97
+ return [CLIResult(
98
+ spec=spec, ok=True, stdout="mocked answer",
99
+ stderr="", duration_s=0.42, returncode=0,
100
+ argv=["fake", "claude", "-p", prompt], skipped_reason=None,
101
+ )]
102
+
103
+ monkeypatch.setattr("paircode.runner.run", fake_run)
104
+
105
+ out_path = tmp_path / "peer-a-v1.md"
106
+ result = run_peer(
107
+ peer_id="peer-a-fake",
108
+ cli="claude",
109
+ prompt="what is TCP?",
110
+ output_path=out_path,
111
+ timeout_s=30,
112
+ )
113
+
114
+ # Returns a proper PeerRunResult
115
+ assert isinstance(result, PeerRunResult)
116
+ assert result.peer_id == "peer-a-fake"
117
+ assert result.cli == "claude"
118
+ assert result.ok is True
119
+ assert result.stdout == "mocked answer"
120
+ assert result.duration_s == pytest.approx(0.42)
121
+
122
+ # File-trace landed with the correct header
123
+ assert out_path.exists()
124
+ content = out_path.read_text()
125
+ assert "peer_id: peer-a-fake" in content
126
+ assert "cli: claude" in content
127
+ assert "ok: True" in content
128
+ assert "mocked answer" in content
129
+
130
+
131
+ def test_run_peer_with_failure_still_writes_trace(monkeypatch, tmp_path):
132
+ """Failed peer runs must still leave a file-trace on disk, with the
133
+ failure captured — that's the whole point of 'every thought on disk'."""
134
+ from paircode.runner import run_peer
135
+ from cliworker.core import CLIResult
136
+ from cliworker import get_spec
137
+
138
+ def fake_run(prompt, *specs, **kwargs):
139
+ spec = specs[0] if specs else get_spec("claude")
140
+ return [CLIResult(
141
+ spec=spec, ok=False, stdout="",
142
+ stderr="subscription lapsed",
143
+ duration_s=1.5, returncode=1, argv=[], skipped_reason=None,
144
+ )]
145
+
146
+ monkeypatch.setattr("paircode.runner.run", fake_run)
147
+
148
+ out_path = tmp_path / "peer-v1.md"
149
+ result = run_peer(
150
+ peer_id="peer-x", cli="claude", prompt="hi",
151
+ output_path=out_path,
152
+ )
153
+ assert result.ok is False
154
+ assert out_path.exists()
155
+ content = out_path.read_text()
156
+ assert "FAILED" in content
157
+ assert "subscription lapsed" in content
158
+
159
+
160
+ def test_run_peer_fast_kwarg_passes_through(monkeypatch, tmp_path):
161
+ """run_peer(fast=True) must land as fast=True on cliworker.run()."""
162
+ from paircode.runner import run_peer
163
+ from cliworker.core import CLIResult
164
+ from cliworker import get_spec
165
+
166
+ captured = {}
167
+
168
+ def fake_run(prompt, *specs, **kwargs):
169
+ captured["fast"] = kwargs.get("fast")
170
+ return [CLIResult(
171
+ spec=specs[0], ok=True, stdout="ok", stderr="",
172
+ duration_s=0.1, returncode=0, argv=[], skipped_reason=None,
173
+ )]
174
+
175
+ monkeypatch.setattr("paircode.runner.run", fake_run)
176
+
177
+ run_peer(
178
+ peer_id="p", cli="claude", prompt="hi",
179
+ output_path=tmp_path / "x.md",
180
+ fast=True,
181
+ )
182
+ assert captured["fast"] is True
183
+
184
+ # And default: no fast
185
+ captured.clear()
186
+ run_peer(
187
+ peer_id="p", cli="claude", prompt="hi",
188
+ output_path=tmp_path / "y.md",
189
+ )
190
+ assert captured.get("fast") in (None, False)
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Installer: codex no-op + legacy cleanup
195
+ # ---------------------------------------------------------------------------
196
+
197
+ def test_installer_writes_codex_prompt_never_broken_rules_file(tmp_path, monkeypatch):
198
+ """Regression of the 'broken codex rules' bug: installer must write to
199
+ ~/.codex/prompts/paircode.md (the correct path) and NEVER to
200
+ ~/.codex/rules/paircode.rules (that breaks codex's Starlark rule loader)."""
201
+ fake_home = tmp_path / "home"
202
+ fake_home.mkdir()
203
+ monkeypatch.setenv("HOME", str(fake_home))
204
+ import importlib
205
+
206
+ import paircode.detect as d
207
+ import paircode.installer as inst
208
+
209
+ importlib.reload(d)
210
+ importlib.reload(inst)
211
+
212
+ monkeypatch.setattr("shutil.which", lambda b: f"/fake/{b}")
213
+ results = inst.install_all()
214
+
215
+ actions = {r.cli_name: r.action for r in results}
216
+ assert actions["codex"] == "installed"
217
+ assert (fake_home / ".codex" / "prompts" / "paircode.md").exists()
218
+ assert not (fake_home / ".codex" / "rules" / "paircode.rules").exists()
219
+
220
+
221
+ def test_installer_writes_gemini_toml_command(tmp_path, monkeypatch):
222
+ """Gemini slash commands are TOML (not markdown). Path must be
223
+ ~/.gemini/commands/paircode.toml with description + prompt fields."""
224
+ fake_home = tmp_path / "home"
225
+ fake_home.mkdir()
226
+ monkeypatch.setenv("HOME", str(fake_home))
227
+ import importlib
228
+
229
+ import paircode.detect as d
230
+ import paircode.installer as inst
231
+
232
+ importlib.reload(d)
233
+ importlib.reload(inst)
234
+
235
+ monkeypatch.setattr("shutil.which", lambda b: f"/fake/{b}")
236
+ results = inst.install_all()
237
+
238
+ actions = {r.cli_name: r.action for r in results}
239
+ assert actions["gemini"] == "installed"
240
+ target = fake_home / ".gemini" / "commands" / "paircode.toml"
241
+ assert target.exists()
242
+ content = target.read_text()
243
+ assert 'description' in content
244
+ assert 'prompt' in content
245
+ # TOML syntax sanity — triple-quoted prompt
246
+ assert '"""' in content
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # Init + status dispatch smoke
251
+ # ---------------------------------------------------------------------------
252
+
253
+ def test_init_creates_paircode_dir(tmp_path, monkeypatch):
254
+ monkeypatch.chdir(tmp_path)
255
+ runner = CliRunner()
256
+ result = runner.invoke(main, ["init"])
257
+ assert result.exit_code == 0, result.output
258
+ assert (tmp_path / ".paircode").exists()
259
+ assert (tmp_path / ".paircode" / "JOURNEY.md").exists()
260
+ assert (tmp_path / ".paircode" / "peers.yaml").exists()
261
+
262
+
263
+ def test_status_finds_paircode_dir(tmp_path, monkeypatch):
264
+ monkeypatch.chdir(tmp_path)
265
+ runner = CliRunner()
266
+ # First init
267
+ runner.invoke(main, ["init"])
268
+ # Then status should find it and exit cleanly
269
+ result = runner.invoke(main, ["status"])
270
+ assert result.exit_code == 0
271
+ assert ".paircode" in result.output
272
+
273
+
274
+ def test_status_without_paircode_dir_prints_hint(tmp_path, monkeypatch):
275
+ monkeypatch.chdir(tmp_path)
276
+ runner = CliRunner()
277
+ result = runner.invoke(main, ["status"])
278
+ # Should not crash; should suggest init
279
+ assert result.exit_code == 0
280
+ assert "init" in result.output.lower()
@@ -76,9 +76,65 @@ def test_install_writes_to_tmp_claude(tmp_path, monkeypatch):
76
76
 
77
77
  results = inst.install_all()
78
78
  actions = {r.cli_name: r.action for r in results}
79
+ # v0.9: all three CLIs get a real slash command file drop.
79
80
  assert actions["claude"] == "installed"
80
- # Verify file landed
81
+ assert actions["codex"] == "installed"
82
+ assert actions["gemini"] == "installed"
83
+
84
+ # Correct paths per CLI
81
85
  claude_cmd = fake_home / ".claude" / "commands" / "paircode.md"
82
86
  assert claude_cmd.exists()
83
- content = claude_cmd.read_text()
84
- assert "paircode" in content
87
+ assert "paircode" in claude_cmd.read_text()
88
+
89
+ codex_cmd = fake_home / ".codex" / "prompts" / "paircode.md"
90
+ assert codex_cmd.exists(), (
91
+ f"v0.9 must write codex slash command to {codex_cmd}"
92
+ )
93
+ assert "paircode" in codex_cmd.read_text()
94
+
95
+ gemini_cmd = fake_home / ".gemini" / "commands" / "paircode.toml"
96
+ assert gemini_cmd.exists(), (
97
+ f"v0.9 must write gemini slash command to {gemini_cmd} (note: TOML, not markdown)"
98
+ )
99
+ gemini_content = gemini_cmd.read_text()
100
+ assert "description" in gemini_content
101
+ assert "prompt" in gemini_content
102
+
103
+ # And verify we still do NOT write the legacy broken codex rules file
104
+ codex_rules = fake_home / ".codex" / "rules" / "paircode.rules"
105
+ assert not codex_rules.exists(), (
106
+ f"paircode must never write {codex_rules} — that file breaks codex's rule loader"
107
+ )
108
+
109
+
110
+ def test_install_cleans_legacy_codex_rules_file(tmp_path, monkeypatch):
111
+ """Users who installed paircode 0.1–0.7 have a broken ~/.codex/rules/paircode.rules.
112
+ Running install again (or any time) must clean it up."""
113
+ fake_home = tmp_path / "home"
114
+ fake_home.mkdir()
115
+ monkeypatch.setenv("HOME", str(fake_home))
116
+ import importlib
117
+
118
+ import paircode.detect as d
119
+ import paircode.installer as inst
120
+
121
+ importlib.reload(d)
122
+ importlib.reload(inst)
123
+
124
+ # Seed the legacy broken file
125
+ legacy = fake_home / ".codex" / "rules" / "paircode.rules"
126
+ legacy.parent.mkdir(parents=True)
127
+ legacy.write_text("# old broken markdown-in-starlark\n")
128
+ assert legacy.exists()
129
+
130
+ def fake_which(binary):
131
+ return f"/fake/bin/{binary}"
132
+
133
+ monkeypatch.setattr("shutil.which", fake_which)
134
+ inst.install_all()
135
+
136
+ # Legacy file should be gone after install_all
137
+ assert not legacy.exists(), (
138
+ "install_all must remove the legacy ~/.codex/rules/paircode.rules "
139
+ "(it's Starlark-parsed and our markdown broke codex)"
140
+ )
@@ -1,161 +0,0 @@
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
@@ -1,114 +0,0 @@
1
- """Invoke peer LLM CLIs as subprocesses, capture output to .md files.
2
-
3
- This is the "spawn an LLM, write its answer to disk" primitive. Each CLI
4
- has different flags for non-interactive / prompt-mode; we normalize them
5
- here so upstream code just says "ask <peer> this prompt, save to <path>".
6
- """
7
- from __future__ import annotations
8
-
9
- import subprocess
10
- from dataclasses import dataclass
11
- from pathlib import Path
12
- from typing import Optional
13
-
14
-
15
- DEFAULT_TIMEOUT_SECONDS = 600 # 10 min per peer call — generous for cold research
16
-
17
-
18
- @dataclass(frozen=True)
19
- class PeerRunResult:
20
- peer_id: str
21
- cli: str
22
- ok: bool
23
- stdout: str
24
- stderr: str
25
- duration_s: float
26
- command: list[str]
27
-
28
-
29
- def _cli_command(cli: str, prompt: str, model: Optional[str]) -> list[str]:
30
- """Return the subprocess argv to invoke `cli` non-interactively with `prompt`."""
31
- if cli == "claude":
32
- # Claude Code: -p is non-interactive (print) mode
33
- cmd = ["claude", "-p", prompt]
34
- if model:
35
- cmd.extend(["--model", model])
36
- return cmd
37
- if cli == "codex":
38
- # Codex CLI: `codex exec` is non-interactive; --dangerously-bypass needed to
39
- # write files / skip approvals when running headless.
40
- cmd = ["codex", "exec", "--dangerously-bypass-approvals-and-sandbox", prompt]
41
- if model:
42
- cmd.extend(["--model", model])
43
- return cmd
44
- if cli == "gemini":
45
- cmd = ["gemini", "-p", prompt]
46
- if model:
47
- cmd.extend(["--model", model])
48
- return cmd
49
- if cli == "ollama":
50
- # Ollama needs a model name
51
- return ["ollama", "run", model or "llama3.1", prompt]
52
- # Unknown CLI — best-effort: pass prompt as last arg
53
- return [cli, prompt]
54
-
55
-
56
- def run_peer(
57
- peer_id: str,
58
- cli: str,
59
- prompt: str,
60
- output_path: Path,
61
- model: Optional[str] = None,
62
- timeout_s: int = DEFAULT_TIMEOUT_SECONDS,
63
- ) -> PeerRunResult:
64
- """Run one peer LLM against `prompt`, write its stdout to `output_path`.
65
-
66
- Returns a PeerRunResult capturing success/failure + full stdout + stderr.
67
- The output file is written regardless of success (empty or error-captured
68
- text) so that every invocation leaves a file-trace on disk.
69
- """
70
- import time
71
-
72
- cmd = _cli_command(cli, prompt, model)
73
- start = time.monotonic()
74
- try:
75
- proc = subprocess.run(
76
- cmd,
77
- capture_output=True,
78
- text=True,
79
- timeout=timeout_s,
80
- check=False,
81
- )
82
- duration = time.monotonic() - start
83
- ok = proc.returncode == 0
84
- stdout, stderr = proc.stdout, proc.stderr
85
- except FileNotFoundError:
86
- duration = time.monotonic() - start
87
- ok = False
88
- stdout = ""
89
- stderr = f"{cli} binary not found on PATH"
90
- except subprocess.TimeoutExpired:
91
- duration = time.monotonic() - start
92
- ok = False
93
- stdout = ""
94
- stderr = f"{cli} timed out after {timeout_s}s"
95
-
96
- output_path.parent.mkdir(parents=True, exist_ok=True)
97
- header = (
98
- f"<!-- peer_id: {peer_id} -->\n"
99
- f"<!-- cli: {cli} -->\n"
100
- f"<!-- model: {model or '(default)'} -->\n"
101
- f"<!-- duration_s: {duration:.1f} -->\n"
102
- f"<!-- ok: {ok} -->\n\n"
103
- )
104
- body = stdout if ok else f"# Peer run FAILED\n\n```\n{stderr}\n```\n\n{stdout}"
105
- output_path.write_text(header + body, encoding="utf-8")
106
- return PeerRunResult(
107
- peer_id=peer_id,
108
- cli=cli,
109
- ok=ok,
110
- stdout=stdout,
111
- stderr=stderr,
112
- duration_s=duration,
113
- command=cmd,
114
- )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes