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 +1 -0
- piocloop/__main__.py +3 -0
- piocloop/cli.py +230 -0
- piocloop/loop.py +487 -0
- piocloop/pi_client.py +441 -0
- piocloop/pi_events.py +326 -0
- piocloop/plan_parser.py +140 -0
- piocloop/tui.py +289 -0
- piocloop-0.1.0.dist-info/METADATA +191 -0
- piocloop-0.1.0.dist-info/RECORD +13 -0
- piocloop-0.1.0.dist-info/WHEEL +4 -0
- piocloop-0.1.0.dist-info/entry_points.txt +2 -0
- piocloop-0.1.0.dist-info/licenses/LICENSE +21 -0
piocloop/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
piocloop/__main__.py
ADDED
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)")
|