piocloop 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
piocloop/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
piocloop/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ app()
piocloop/cli.py ADDED
@@ -0,0 +1,230 @@
1
+ """CLI entry point for piocloop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import List, Optional
10
+
11
+ import typer
12
+
13
+ app = typer.Typer(
14
+ name="piloop",
15
+ help="piocloop — orchestrate the PI coding agent to execute tasks from a PLAN.md file iteratively.",
16
+ add_completion=False,
17
+ no_args_is_help=True,
18
+ )
19
+
20
+ TESTED_PI_VERSIONS = ("0.82",)
21
+
22
+ _PLAN_TEMPLATE = """\
23
+ # Project Plan
24
+
25
+ ## Overview
26
+
27
+ Describe the goal of this project here.
28
+
29
+ ## Backlog
30
+
31
+ ### Phase 1
32
+
33
+ - [ ] First task description
34
+ - [ ] Second task description
35
+ - [ ] Third task description
36
+ """
37
+
38
+ _PROMPT_TEMPLATE = """\
39
+ Execute the next task from {{PLAN_FILE}}.
40
+
41
+ Before starting:
42
+ 1. Read {{PLAN_FILE}} fully
43
+
44
+ Task selection (CRITICAL):
45
+ - Work through phases IN ORDER — complete Phase N before starting Phase N+1
46
+ - Pick the FIRST uncompleted task in the earliest incomplete phase
47
+ - Skip [MANUAL] and [BLOCKED] items
48
+ - NEVER batch tasks across different phases
49
+
50
+ Execute:
51
+ 1. Apply the requested changes
52
+
53
+ After completion:
54
+ 1. Update {{PLAN_FILE}} marking completed items with [x]
55
+
56
+ 2. If you cannot complete a task (permissions, external service, needs human input):
57
+ - Add [BLOCKED: reason] to that task line in {{PLAN_FILE}}
58
+ - Continue with other tasks
59
+
60
+ Completion check:
61
+ - If all non-[MANUAL] tasks are either [x] or [BLOCKED]:
62
+ - Append `<plan-complete>SUMMARY_OF_WORK_DONE_AND_REMAINING_MANUAL_TASKS</plan-complete>`
63
+ to the end of {{PLAN_FILE}}, at the start of a line
64
+ - Stop
65
+ - Do NOT skip automatable tasks — if a task seems hard but doable, attempt it
66
+ """
67
+
68
+
69
+ def _pi_version(pi_bin: str) -> Optional[str]:
70
+ try:
71
+ out = subprocess.run(
72
+ [pi_bin, "--version"], capture_output=True, text=True, timeout=20
73
+ )
74
+ except (OSError, subprocess.SubprocessError):
75
+ return None
76
+ return (out.stdout or out.stderr).strip().splitlines()[0] if out.returncode == 0 else None
77
+
78
+
79
+ @app.command()
80
+ def run(
81
+ model: Optional[str] = typer.Option(
82
+ None, "-m", "--model",
83
+ help="Model pattern or provider/id, e.g. zai/glm-5.2. List with: pi --list-models",
84
+ ),
85
+ thinking: Optional[str] = typer.Option(
86
+ None, "--thinking",
87
+ help="Thinking level: off|minimal|low|medium|high|xhigh|max",
88
+ ),
89
+ prompt: Path = typer.Option(".loop-prompt.md", "--prompt", help="Path to loop prompt file"),
90
+ plan: Path = typer.Option("PLAN.md", "--plan", help="Path to plan file"),
91
+ run_now: bool = typer.Option(False, "-r", "--run", help="Start iterations immediately"),
92
+ max_iterations: int = typer.Option(100, "--max-iterations", help="Hard stop after N iterations"),
93
+ iteration_timeout: float = typer.Option(
94
+ 1800.0, "--iteration-timeout", help="Seconds before an iteration is aborted"
95
+ ),
96
+ max_stalls: int = typer.Option(
97
+ 3, "--max-stalls", help="Stop after N iterations with no plan progress (0 disables)"
98
+ ),
99
+ dialog_policy: str = typer.Option(
100
+ "cancel", "--dialog-policy",
101
+ help="How to answer blocking extension dialogs: cancel|allow|deny",
102
+ ),
103
+ session_dir: Optional[Path] = typer.Option(None, "--session-dir", help="Passed through to pi"),
104
+ no_session: bool = typer.Option(False, "--no-session", help="Do not persist pi sessions"),
105
+ tools: Optional[str] = typer.Option(None, "--tools", help="Comma-separated tool allowlist"),
106
+ exclude_tools: Optional[str] = typer.Option(
107
+ None, "--exclude-tools", help="Comma-separated tool denylist"
108
+ ),
109
+ append_system_prompt: List[str] = typer.Option(
110
+ [], "--append-system-prompt", help="Text or file appended to pi's system prompt (repeatable)"
111
+ ),
112
+ skill: List[str] = typer.Option([], "--skill", help="Skill file or directory (repeatable)"),
113
+ approve: Optional[bool] = typer.Option(
114
+ None, "--approve/--no-approve", help="Trust (or ignore) project-local pi files"
115
+ ),
116
+ pi_bin: str = typer.Option("pi", "--pi-bin", help="Path to the pi executable"),
117
+ debug: bool = typer.Option(False, "-d", "--debug", help="Skip plan/prompt file validation"),
118
+ verbose: bool = typer.Option(False, "--verbose", help="Log every raw RPC event"),
119
+ log: Optional[Path] = typer.Option(None, "--log", help="Append all log entries to this file"),
120
+ ) -> None:
121
+ """Run the piocloop orchestration loop."""
122
+ directory = os.getcwd()
123
+ prompt_abs = prompt.resolve()
124
+ plan_abs = plan.resolve()
125
+
126
+ if dialog_policy not in ("cancel", "allow", "deny"):
127
+ typer.echo(f"Error: --dialog-policy must be cancel|allow|deny, got {dialog_policy!r}", err=True)
128
+ raise typer.Exit(2)
129
+
130
+ if shutil.which(pi_bin) is None and not Path(pi_bin).exists():
131
+ typer.echo(f"Error: {pi_bin!r} not found on PATH.", err=True)
132
+ typer.echo("\nInstall it with: npm install -g @earendil-works/pi-coding-agent\n", err=True)
133
+ raise typer.Exit(1)
134
+
135
+ version = _pi_version(pi_bin)
136
+ if version and not any(version.startswith(v) for v in TESTED_PI_VERSIONS):
137
+ typer.echo(
138
+ f"Warning: pi {version} is outside the tested range "
139
+ f"({', '.join(TESTED_PI_VERSIONS)}.x); the RPC protocol may differ.",
140
+ err=True,
141
+ )
142
+
143
+ if not debug:
144
+ if not plan_abs.exists():
145
+ typer.echo(f"Error: Plan file not found: {plan_abs}", err=True)
146
+ typer.echo("\nTip: run piloop bootstrap . to create starter files.\n", err=True)
147
+ raise typer.Exit(1)
148
+ if not prompt_abs.exists():
149
+ typer.echo(f"Error: Prompt file not found: {prompt_abs}", err=True)
150
+ typer.echo("\nTip: run piloop bootstrap . to create starter files.\n", err=True)
151
+ raise typer.Exit(1)
152
+
153
+ from .loop import LoopConfig
154
+ from .pi_client import build_argv
155
+ from .tui import PiloopApp
156
+
157
+ argv = build_argv(
158
+ pi_bin=pi_bin,
159
+ model=model,
160
+ thinking=thinking,
161
+ session_dir=str(session_dir.resolve()) if session_dir else None,
162
+ no_session=no_session,
163
+ tools=tools,
164
+ exclude_tools=exclude_tools,
165
+ append_system_prompt=tuple(append_system_prompt),
166
+ skills=tuple(skill),
167
+ approve=approve,
168
+ )
169
+
170
+ config = LoopConfig(
171
+ prompt_file=prompt_abs,
172
+ plan_file=plan_abs,
173
+ argv=argv,
174
+ cwd=directory,
175
+ dialog_policy=dialog_policy,
176
+ max_iterations=max_iterations,
177
+ iteration_timeout=iteration_timeout,
178
+ max_stalls=max_stalls,
179
+ verbose=verbose,
180
+ )
181
+
182
+ PiloopApp(config, model=model, auto_run=run_now, log_file=log).run()
183
+
184
+
185
+ @app.command()
186
+ def bootstrap(
187
+ directory: Path = typer.Argument(Path("."), help="Directory to initialise"),
188
+ force: bool = typer.Option(False, "-f", "--force", help="Overwrite existing files"),
189
+ ) -> None:
190
+ """Create a starter PLAN.md and .loop-prompt.md in DIRECTORY."""
191
+ directory = directory.resolve()
192
+ directory.mkdir(parents=True, exist_ok=True)
193
+
194
+ plan_file = directory / "PLAN.md"
195
+ prompt_file = directory / ".loop-prompt.md"
196
+
197
+ created, skipped = [], []
198
+ for path, content in [(plan_file, _PLAN_TEMPLATE), (prompt_file, _PROMPT_TEMPLATE)]:
199
+ if path.exists() and not force:
200
+ skipped.append(path.name)
201
+ else:
202
+ path.write_text(content, encoding="utf-8")
203
+ created.append(path.name)
204
+
205
+ for name in created:
206
+ typer.echo(f" created {directory / name}")
207
+ for name in skipped:
208
+ typer.echo(f" skipped {directory / name} (exists; use --force to overwrite)")
209
+
210
+ if created:
211
+ typer.echo("\nNext steps:")
212
+ typer.echo(f" 1. Edit {plan_file} — add your tasks")
213
+ typer.echo(f" 2. Edit {prompt_file} — adjust instructions if needed")
214
+ typer.echo(" 3. Run: piloop run --model <provider/model>")
215
+
216
+
217
+ @app.command()
218
+ def doctor(
219
+ pi_bin: str = typer.Option("pi", "--pi-bin", help="Path to the pi executable"),
220
+ ) -> None:
221
+ """Check that pi is installed and reachable."""
222
+ path = shutil.which(pi_bin)
223
+ if path is None:
224
+ typer.echo(f"pi: NOT FOUND ({pi_bin!r} is not on PATH)")
225
+ raise typer.Exit(1)
226
+ version = _pi_version(pi_bin)
227
+ typer.echo(f"pi: {path}")
228
+ typer.echo(f"version: {version or 'unknown'}")
229
+ if version and not any(version.startswith(v) for v in TESTED_PI_VERSIONS):
230
+ typer.echo(f"warning: outside tested range ({', '.join(TESTED_PI_VERSIONS)}.x)")