pmflow-cli 0.1.2__tar.gz → 0.1.4__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.
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/.gitignore +3 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/PKG-INFO +2 -2
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/pyproject.toml +2 -2
- pmflow_cli-0.1.4/src/pmflow_cli/commands/runner.py +503 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/commands/skills.py +30 -15
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/main.py +2 -1
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/.claude-plugin/plugin.json +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/README.md +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/skills/plan-space/SKILL.md +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/skills/plan-task/SKILL.md +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/skills/prd/SKILL.md +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/skills/prdfix/SKILL.md +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/skills/snapshot-space/SKILL.md +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/__init__.py +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/client.py +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/commands/__init__.py +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/commands/ai.py +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/commands/auth.py +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/commands/context.py +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/commands/issue.py +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/commands/workspace.py +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/config.py +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/output.py +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/skills/plan-space/SKILL.md +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/skills/plan-task/SKILL.md +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/skills/prd/SKILL.md +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/skills/prdfix/SKILL.md +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/skills/snapshot-space/SKILL.md +0 -0
- {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pmflow-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: CLI for pmflow — project management for AI-assisted development
|
|
5
5
|
Project-URL: Repository, https://github.com/zhanglaixian/pmflow
|
|
6
6
|
Author-email: zhanglaixian <zhanglaixian@zhuanzhuan.com>
|
|
@@ -11,7 +11,7 @@ Classifier: License :: OSI Approved :: MIT License
|
|
|
11
11
|
Classifier: Programming Language :: Python :: 3
|
|
12
12
|
Requires-Python: >=3.12
|
|
13
13
|
Requires-Dist: httpx[socks]>=0.27
|
|
14
|
-
Requires-Dist: pmflow-shared>=0.1.
|
|
14
|
+
Requires-Dist: pmflow-shared>=0.1.3
|
|
15
15
|
Requires-Dist: rich>=13.0
|
|
16
16
|
Requires-Dist: tomli-w>=1.0
|
|
17
17
|
Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pmflow-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.4"
|
|
4
4
|
description = "CLI for pmflow — project management for AI-assisted development"
|
|
5
5
|
requires-python = ">=3.12"
|
|
6
6
|
readme = "README.md"
|
|
@@ -13,7 +13,7 @@ classifiers = [
|
|
|
13
13
|
"Environment :: Console",
|
|
14
14
|
]
|
|
15
15
|
dependencies = [
|
|
16
|
-
"pmflow-shared>=0.1.
|
|
16
|
+
"pmflow-shared>=0.1.3",
|
|
17
17
|
"typer>=0.12",
|
|
18
18
|
"httpx[socks]>=0.27",
|
|
19
19
|
"rich>=13.0",
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""Agent Runner: polls server for tasks and executes them via Claude Code CLI."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from pmflow_cli.client import PmflowClient
|
|
13
|
+
from pmflow_cli.output import console, format_error
|
|
14
|
+
from pmflow_cli.utils import get_workspace, run_async
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(name="runner", help="Agent Runner for automated task execution")
|
|
17
|
+
|
|
18
|
+
WORKTREE_DIR = ".pmflow-worktrees"
|
|
19
|
+
|
|
20
|
+
# Tools allowed during plan stage (read-only + Bash for pmflow CLI)
|
|
21
|
+
PLAN_TOOLS = "Read,Glob,Grep,Bash(pmflow *),Skill"
|
|
22
|
+
|
|
23
|
+
# Tools allowed during execute stage
|
|
24
|
+
EXECUTE_TOOLS = "Edit,Write,Read,Glob,Grep,Bash(git *),Bash(uv run *),Bash(npm *),Bash(pmflow *),Skill"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _build_issue_ref(task: dict) -> str:
|
|
28
|
+
return f"{task['workspace_slug'].upper()}-{task['issue_seq_num']}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_plan_prompt(task: dict) -> str:
|
|
32
|
+
"""Let Claude Code run the plan-task skill to get full context."""
|
|
33
|
+
issue_ref = _build_issue_ref(task)
|
|
34
|
+
|
|
35
|
+
return f"""/pmflow:plan-task {issue_ref}
|
|
36
|
+
|
|
37
|
+
完成方案制定后,在输出的最后,用以下格式标记方案是否需要人工确认:
|
|
38
|
+
- 如果方案中有不确定的点,在输出末尾加上 [NEEDS_INPUT]
|
|
39
|
+
- 如果方案完整可执行,不需要额外标记"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _build_execute_prompt(task: dict) -> str:
|
|
43
|
+
"""Execute the approved plan, then run prdfix to generate summary."""
|
|
44
|
+
issue_ref = _build_issue_ref(task)
|
|
45
|
+
approved_plan = task.get("approved_plan") or ""
|
|
46
|
+
extra_context = task.get("extra_context") or ""
|
|
47
|
+
|
|
48
|
+
parts = [
|
|
49
|
+
f"按照以下已批准的方案实现 {issue_ref}。",
|
|
50
|
+
"",
|
|
51
|
+
"## 批准的方案",
|
|
52
|
+
approved_plan,
|
|
53
|
+
]
|
|
54
|
+
if extra_context:
|
|
55
|
+
parts.extend(["", "## 人工补充的上下文", extra_context])
|
|
56
|
+
parts.extend([
|
|
57
|
+
"",
|
|
58
|
+
"严格按方案执行,不要偏离。完成后提交 commit。",
|
|
59
|
+
"",
|
|
60
|
+
f"代码提交后,执行 /pmflow:prdfix {issue_ref} 生成实现总结。",
|
|
61
|
+
])
|
|
62
|
+
return "\n".join(parts)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _get_main_branch(workdir: str) -> str:
|
|
67
|
+
"""Detect the main branch name (main or master)."""
|
|
68
|
+
try:
|
|
69
|
+
result = subprocess.run(
|
|
70
|
+
["git", "symbolic-ref", "refs/remotes/origin/HEAD"],
|
|
71
|
+
cwd=workdir, capture_output=True, text=True,
|
|
72
|
+
)
|
|
73
|
+
if result.returncode == 0:
|
|
74
|
+
# e.g. "refs/remotes/origin/main" → "main"
|
|
75
|
+
return result.stdout.strip().split("/")[-1]
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
return "main"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _setup_worktree(workdir: str, issue_ref: str) -> tuple[str, str]:
|
|
82
|
+
"""Create a git worktree for the task. Returns (worktree_path, branch_name).
|
|
83
|
+
|
|
84
|
+
Bases the new branch on local main, keeping Runner in sync with the
|
|
85
|
+
developer's local codebase state.
|
|
86
|
+
If the worktree/branch already exists (e.g. task was reset), reuse it.
|
|
87
|
+
"""
|
|
88
|
+
branch = f"agent/{issue_ref.lower()}"
|
|
89
|
+
wt_path = str(Path(workdir) / WORKTREE_DIR / issue_ref)
|
|
90
|
+
|
|
91
|
+
main_branch = _get_main_branch(workdir)
|
|
92
|
+
|
|
93
|
+
if Path(wt_path).exists():
|
|
94
|
+
console.print(f" [dim]Reusing existing worktree: {wt_path}[/dim]")
|
|
95
|
+
subprocess.run(
|
|
96
|
+
["git", "merge", main_branch, "--no-edit"],
|
|
97
|
+
cwd=wt_path, capture_output=True,
|
|
98
|
+
)
|
|
99
|
+
return wt_path, branch
|
|
100
|
+
|
|
101
|
+
# Ensure parent directory exists
|
|
102
|
+
Path(wt_path).parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
|
|
104
|
+
# Check if branch already exists
|
|
105
|
+
check = subprocess.run(
|
|
106
|
+
["git", "rev-parse", "--verify", branch],
|
|
107
|
+
cwd=workdir, capture_output=True,
|
|
108
|
+
)
|
|
109
|
+
if check.returncode == 0:
|
|
110
|
+
proc = subprocess.run(
|
|
111
|
+
["git", "worktree", "add", wt_path, branch],
|
|
112
|
+
cwd=workdir, capture_output=True, text=True,
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
proc = subprocess.run(
|
|
116
|
+
["git", "worktree", "add", "-b", branch, wt_path, main_branch],
|
|
117
|
+
cwd=workdir, capture_output=True, text=True,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if proc.returncode != 0:
|
|
121
|
+
raise RuntimeError(f"Failed to create worktree: {proc.stderr}")
|
|
122
|
+
|
|
123
|
+
console.print(f" [dim]Worktree created: {wt_path} → {branch} (from {main_branch})[/dim]")
|
|
124
|
+
return wt_path, branch
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _print_stream_event(event: dict) -> None:
|
|
128
|
+
"""Print a concise, human-readable line for a stream-json event."""
|
|
129
|
+
etype = event.get("type")
|
|
130
|
+
if etype == "assistant":
|
|
131
|
+
content_blocks = event.get("message", {}).get("content", [])
|
|
132
|
+
for block in content_blocks:
|
|
133
|
+
btype = block.get("type")
|
|
134
|
+
if btype == "text":
|
|
135
|
+
text = block.get("text", "")
|
|
136
|
+
preview = text[:120].replace("\n", " ")
|
|
137
|
+
if len(text) > 120:
|
|
138
|
+
preview += "…"
|
|
139
|
+
console.print(f" [dim]💬 {preview}[/dim]")
|
|
140
|
+
elif btype == "tool_use":
|
|
141
|
+
tool = block.get("name", "?")
|
|
142
|
+
inp = block.get("input", {})
|
|
143
|
+
# Show the most useful argument as a short hint
|
|
144
|
+
hint = ""
|
|
145
|
+
if "command" in inp:
|
|
146
|
+
hint = inp["command"][:80]
|
|
147
|
+
elif "file_path" in inp:
|
|
148
|
+
hint = inp["file_path"]
|
|
149
|
+
elif "pattern" in inp:
|
|
150
|
+
hint = inp["pattern"]
|
|
151
|
+
elif "skill" in inp:
|
|
152
|
+
hint = inp["skill"]
|
|
153
|
+
if hint:
|
|
154
|
+
console.print(f" [cyan]🔧 {tool}[/cyan] [dim]{hint}[/dim]")
|
|
155
|
+
else:
|
|
156
|
+
console.print(f" [cyan]🔧 {tool}[/cyan]")
|
|
157
|
+
elif btype == "thinking":
|
|
158
|
+
console.print(" [dim]🧠 thinking…[/dim]")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _run_claude(
|
|
162
|
+
prompt: str, workdir: str, allowed_tools: str, timeout: int,
|
|
163
|
+
resume_session_id: str | None = None,
|
|
164
|
+
) -> dict:
|
|
165
|
+
"""Run claude CLI in non-interactive mode with streaming output.
|
|
166
|
+
|
|
167
|
+
Uses ``--output-format stream-json`` so we can show real-time progress
|
|
168
|
+
in the terminal. Returns dict with keys: ok, output (str), error (optional),
|
|
169
|
+
session_id (optional).
|
|
170
|
+
"""
|
|
171
|
+
cmd = ["claude", "-p", prompt, "--output-format", "stream-json", "--allowedTools", allowed_tools]
|
|
172
|
+
if resume_session_id:
|
|
173
|
+
cmd.extend(["--resume", resume_session_id])
|
|
174
|
+
try:
|
|
175
|
+
proc = subprocess.Popen(
|
|
176
|
+
cmd,
|
|
177
|
+
cwd=workdir,
|
|
178
|
+
stdout=subprocess.PIPE,
|
|
179
|
+
stderr=subprocess.PIPE,
|
|
180
|
+
text=True,
|
|
181
|
+
)
|
|
182
|
+
except FileNotFoundError:
|
|
183
|
+
return {"ok": False, "output": "", "error": "claude CLI not found. Please install Claude Code."}
|
|
184
|
+
|
|
185
|
+
assistant_texts: list[str] = []
|
|
186
|
+
session_id: str | None = None
|
|
187
|
+
result_text: str | None = None
|
|
188
|
+
try:
|
|
189
|
+
assert proc.stdout is not None
|
|
190
|
+
for line in proc.stdout:
|
|
191
|
+
line = line.strip()
|
|
192
|
+
if not line:
|
|
193
|
+
continue
|
|
194
|
+
try:
|
|
195
|
+
event = json.loads(line)
|
|
196
|
+
_print_stream_event(event)
|
|
197
|
+
etype = event.get("type")
|
|
198
|
+
if etype == "assistant":
|
|
199
|
+
for block in event.get("message", {}).get("content", []):
|
|
200
|
+
if block.get("type") == "text" and block.get("text"):
|
|
201
|
+
assistant_texts.append(block["text"])
|
|
202
|
+
elif etype == "result":
|
|
203
|
+
if event.get("result"):
|
|
204
|
+
result_text = event["result"]
|
|
205
|
+
if event.get("session_id"):
|
|
206
|
+
session_id = event["session_id"]
|
|
207
|
+
except json.JSONDecodeError:
|
|
208
|
+
pass
|
|
209
|
+
proc.wait(timeout=timeout)
|
|
210
|
+
except subprocess.TimeoutExpired:
|
|
211
|
+
proc.kill()
|
|
212
|
+
return {"ok": False, "output": "", "error": "Claude execution timed out"}
|
|
213
|
+
|
|
214
|
+
# Prefer the full conversation text; fall back to result summary
|
|
215
|
+
if assistant_texts:
|
|
216
|
+
text = "\n\n".join(assistant_texts)
|
|
217
|
+
elif result_text:
|
|
218
|
+
text = result_text
|
|
219
|
+
else:
|
|
220
|
+
stderr = proc.stderr.read() if proc.stderr else ""
|
|
221
|
+
text = stderr[:10000] if stderr else ""
|
|
222
|
+
|
|
223
|
+
return {"ok": proc.returncode == 0, "output": text, "session_id": session_id}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@app.command("start")
|
|
227
|
+
def start(
|
|
228
|
+
workspace: str | None = typer.Option(None, "--workspace", "-w"),
|
|
229
|
+
workdir: str | None = typer.Option(None, "--workdir", "-d", help="Working directory (default: cwd)"),
|
|
230
|
+
name: str | None = typer.Option(None, "--name", "-n", help="Runner name (default: hostname)"),
|
|
231
|
+
interval: int = typer.Option(10, "--interval", "-i", help="Poll interval in seconds"),
|
|
232
|
+
timeout: int = typer.Option(900, "--timeout", "-t", help="Claude execution timeout in seconds"),
|
|
233
|
+
auto_approve: bool = typer.Option(False, "--auto-approve", help="Auto-approve plans without [NEEDS_INPUT]"),
|
|
234
|
+
):
|
|
235
|
+
"""Start the Agent Runner daemon. Polls for tasks and executes via Claude Code."""
|
|
236
|
+
LOGO = r"""
|
|
237
|
+
____ __ ___ ______ __
|
|
238
|
+
/ __ \/ |/ // ____// /____ _ __
|
|
239
|
+
/ /_/ / /|_/ // /_ / // __ \| | /| / /
|
|
240
|
+
/ ____/ / / // __/ / // /_/ /| |/ |/ /
|
|
241
|
+
/_/ /_/ /_//_/ /_/ \____/ |__/|__/
|
|
242
|
+
"""
|
|
243
|
+
console.print(f"[bold cyan]{LOGO}[/bold cyan]", highlight=False)
|
|
244
|
+
console.print(" [dim]Agent Runner[/dim]\n")
|
|
245
|
+
|
|
246
|
+
slug = get_workspace(workspace)
|
|
247
|
+
work_path = workdir or str(Path.cwd())
|
|
248
|
+
runner_id = _get_runner_id(name)
|
|
249
|
+
|
|
250
|
+
# Register: first heartbeat verifies server connectivity
|
|
251
|
+
console.print(f"[dim]Registering runner [bold]{runner_id}[/bold] ...[/dim]")
|
|
252
|
+
try:
|
|
253
|
+
_register_runner(slug, runner_id)
|
|
254
|
+
except Exception as e:
|
|
255
|
+
console.print(f"[red]Failed to register runner:[/red] {format_error(e)}")
|
|
256
|
+
console.print("[dim]Check server connectivity and authentication.[/dim]")
|
|
257
|
+
raise typer.Exit(1) from None
|
|
258
|
+
|
|
259
|
+
console.print("[bold green]Agent Runner started[/bold green]")
|
|
260
|
+
console.print(f" Runner ID: [cyan]{runner_id}[/cyan]")
|
|
261
|
+
console.print(f" Workspace: [cyan]{slug}[/cyan]")
|
|
262
|
+
console.print(f" Workdir: [cyan]{work_path}[/cyan]")
|
|
263
|
+
console.print(f" Interval: [cyan]{interval}s[/cyan]")
|
|
264
|
+
console.print(f" Timeout: [cyan]{timeout}s[/cyan]")
|
|
265
|
+
console.print(f" Auto-approve: [cyan]{auto_approve}[/cyan]")
|
|
266
|
+
console.print()
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
while True:
|
|
270
|
+
try:
|
|
271
|
+
_poll_once(slug, work_path, timeout, auto_approve, runner_id)
|
|
272
|
+
except KeyboardInterrupt:
|
|
273
|
+
raise
|
|
274
|
+
except Exception as e:
|
|
275
|
+
console.print(f"[red]Poll error:[/red] {format_error(e)}")
|
|
276
|
+
|
|
277
|
+
time.sleep(interval)
|
|
278
|
+
except KeyboardInterrupt:
|
|
279
|
+
console.print(f"\n[yellow]Shutting down runner {runner_id} ...[/yellow]")
|
|
280
|
+
_send_offline(slug, runner_id)
|
|
281
|
+
console.print("[yellow]Runner stopped.[/yellow]")
|
|
282
|
+
raise typer.Exit(0) from None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@app.command("run")
|
|
286
|
+
def run_once(
|
|
287
|
+
issue_ref: str = typer.Argument(help="Issue reference (e.g. WS-3)"),
|
|
288
|
+
workspace: str | None = typer.Option(None, "--workspace", "-w"),
|
|
289
|
+
workdir: str | None = typer.Option(None, "--workdir", "-d"),
|
|
290
|
+
timeout: int = typer.Option(900, "--timeout", "-t"),
|
|
291
|
+
):
|
|
292
|
+
"""Create and execute a single agent task (for debugging)."""
|
|
293
|
+
slug = get_workspace(workspace)
|
|
294
|
+
work_path = workdir or str(Path.cwd())
|
|
295
|
+
|
|
296
|
+
# Parse issue ref
|
|
297
|
+
parts = issue_ref.upper().split("-")
|
|
298
|
+
if len(parts) != 2 or not parts[1].isdigit():
|
|
299
|
+
console.print(f"[red]Invalid issue ref:[/red] {issue_ref}. Expected format: WS-3")
|
|
300
|
+
raise typer.Exit(1)
|
|
301
|
+
seq_num = int(parts[1])
|
|
302
|
+
|
|
303
|
+
# Create a task
|
|
304
|
+
async def _create():
|
|
305
|
+
async with PmflowClient() as client:
|
|
306
|
+
resp = await client.http.post(
|
|
307
|
+
f"/workspaces/{slug}/agent-tasks",
|
|
308
|
+
json={"issue_seq_num": seq_num},
|
|
309
|
+
)
|
|
310
|
+
resp.raise_for_status()
|
|
311
|
+
return resp.json()
|
|
312
|
+
|
|
313
|
+
task_data = run_async(_create())
|
|
314
|
+
console.print(f"[green]Task created:[/green] {task_data['id']}")
|
|
315
|
+
|
|
316
|
+
# Claim and execute plan stage
|
|
317
|
+
_poll_once(slug, work_path, timeout, auto_approve=False)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _get_runner_id(name: str | None = None) -> str:
|
|
321
|
+
"""Return a stable runner identifier. Uses --name if provided, otherwise hostname."""
|
|
322
|
+
return name or platform.node()
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _send_heartbeat(slug: str, runner_id: str) -> None:
|
|
326
|
+
"""Send heartbeat to server."""
|
|
327
|
+
async def _hb():
|
|
328
|
+
async with PmflowClient() as client:
|
|
329
|
+
resp = await client.http.post(
|
|
330
|
+
f"/workspaces/{slug}/runners/heartbeat",
|
|
331
|
+
json={"runner_id": runner_id},
|
|
332
|
+
)
|
|
333
|
+
resp.raise_for_status()
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
run_async(_hb())
|
|
337
|
+
except Exception:
|
|
338
|
+
pass # heartbeat failure is non-fatal
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _send_offline(slug: str, runner_id: str) -> None:
|
|
342
|
+
"""Notify server this runner is going offline."""
|
|
343
|
+
async def _off():
|
|
344
|
+
async with PmflowClient() as client:
|
|
345
|
+
resp = await client.http.post(
|
|
346
|
+
f"/workspaces/{slug}/runners/offline",
|
|
347
|
+
json={"runner_id": runner_id},
|
|
348
|
+
)
|
|
349
|
+
resp.raise_for_status()
|
|
350
|
+
|
|
351
|
+
try:
|
|
352
|
+
run_async(_off())
|
|
353
|
+
except Exception:
|
|
354
|
+
pass # best-effort
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _register_runner(slug: str, runner_id: str) -> None:
|
|
358
|
+
"""Register runner on startup. Raises on failure to verify connectivity."""
|
|
359
|
+
async def _reg():
|
|
360
|
+
async with PmflowClient() as client:
|
|
361
|
+
resp = await client.http.post(
|
|
362
|
+
f"/workspaces/{slug}/runners/heartbeat",
|
|
363
|
+
json={"runner_id": runner_id},
|
|
364
|
+
)
|
|
365
|
+
resp.raise_for_status()
|
|
366
|
+
return resp.json()
|
|
367
|
+
|
|
368
|
+
run_async(_reg())
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _poll_once(slug: str, workdir: str, timeout: int, auto_approve: bool, runner_id: str | None = None) -> None:
|
|
372
|
+
"""Poll server for one task and process it."""
|
|
373
|
+
runner_id = runner_id or _get_runner_id()
|
|
374
|
+
|
|
375
|
+
# Send heartbeat every poll cycle
|
|
376
|
+
_send_heartbeat(slug, runner_id)
|
|
377
|
+
|
|
378
|
+
async def _claim():
|
|
379
|
+
async with PmflowClient() as client:
|
|
380
|
+
resp = await client.http.post(
|
|
381
|
+
f"/workspaces/{slug}/agent-tasks/claim",
|
|
382
|
+
json={"runner_id": runner_id},
|
|
383
|
+
)
|
|
384
|
+
resp.raise_for_status()
|
|
385
|
+
return resp.json()
|
|
386
|
+
|
|
387
|
+
data = run_async(_claim())
|
|
388
|
+
|
|
389
|
+
# No task available
|
|
390
|
+
if data.get("task") is None and "id" not in data:
|
|
391
|
+
return
|
|
392
|
+
|
|
393
|
+
task = data
|
|
394
|
+
task_id = task["id"]
|
|
395
|
+
task_status = task["status"]
|
|
396
|
+
issue_ref = _build_issue_ref(task)
|
|
397
|
+
|
|
398
|
+
if task_status == "planning":
|
|
399
|
+
_handle_plan_stage(slug, task_id, issue_ref, task, workdir, timeout, auto_approve)
|
|
400
|
+
elif task_status == "executing":
|
|
401
|
+
_handle_execute_stage(slug, task_id, issue_ref, task, workdir, timeout)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _handle_plan_stage(
|
|
405
|
+
slug: str, task_id: str, issue_ref: str, task: dict,
|
|
406
|
+
workdir: str, timeout: int, auto_approve: bool,
|
|
407
|
+
) -> None:
|
|
408
|
+
console.print(f"[bold blue]▶ Planning[/bold blue] {issue_ref}: {task['issue_title']}")
|
|
409
|
+
|
|
410
|
+
prompt = _build_plan_prompt(task)
|
|
411
|
+
result = _run_claude(prompt, workdir, PLAN_TOOLS, timeout)
|
|
412
|
+
|
|
413
|
+
output_text = result["output"]
|
|
414
|
+
needs_input = "[NEEDS_INPUT]" in output_text
|
|
415
|
+
session_id = result.get("session_id")
|
|
416
|
+
|
|
417
|
+
# Submit plan + session_id to server
|
|
418
|
+
async def _submit():
|
|
419
|
+
async with PmflowClient() as client:
|
|
420
|
+
resp = await client.http.post(
|
|
421
|
+
f"/workspaces/{slug}/agent-tasks/{task_id}/plan",
|
|
422
|
+
json={
|
|
423
|
+
"plan": output_text[:50000],
|
|
424
|
+
"needs_input": needs_input,
|
|
425
|
+
"session_id": session_id,
|
|
426
|
+
},
|
|
427
|
+
)
|
|
428
|
+
resp.raise_for_status()
|
|
429
|
+
return resp.json()
|
|
430
|
+
|
|
431
|
+
run_async(_submit())
|
|
432
|
+
|
|
433
|
+
if session_id:
|
|
434
|
+
console.print(f" [dim]Session: {session_id}[/dim]")
|
|
435
|
+
|
|
436
|
+
if needs_input:
|
|
437
|
+
console.print(" [yellow]⚠ Needs human input[/yellow] — waiting for review")
|
|
438
|
+
elif auto_approve:
|
|
439
|
+
# Auto-approve: submit approval immediately
|
|
440
|
+
async def _approve():
|
|
441
|
+
async with PmflowClient() as client:
|
|
442
|
+
resp = await client.http.post(
|
|
443
|
+
f"/workspaces/{slug}/agent-tasks/{task_id}/approve",
|
|
444
|
+
json={"approved_plan": output_text[:50000]},
|
|
445
|
+
)
|
|
446
|
+
resp.raise_for_status()
|
|
447
|
+
|
|
448
|
+
run_async(_approve())
|
|
449
|
+
console.print(" [green]✓ Plan auto-approved[/green]")
|
|
450
|
+
else:
|
|
451
|
+
console.print(" [cyan]📋 Plan submitted[/cyan] — waiting for human approval")
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _handle_execute_stage(
|
|
455
|
+
slug: str, task_id: str, issue_ref: str, task: dict,
|
|
456
|
+
workdir: str, timeout: int,
|
|
457
|
+
) -> None:
|
|
458
|
+
console.print(f"[bold green]▶ Executing[/bold green] {issue_ref}: {task.get('issue_title', '')}")
|
|
459
|
+
|
|
460
|
+
# Create isolated worktree for this task
|
|
461
|
+
try:
|
|
462
|
+
wt_path, branch = _setup_worktree(workdir, issue_ref)
|
|
463
|
+
except RuntimeError as e:
|
|
464
|
+
console.print(f" [red]✗ Worktree setup failed:[/red] {e}")
|
|
465
|
+
async def _fail():
|
|
466
|
+
async with PmflowClient() as client:
|
|
467
|
+
resp = await client.http.post(
|
|
468
|
+
f"/workspaces/{slug}/agent-tasks/{task_id}/result",
|
|
469
|
+
json={"status": "failed", "error": str(e)},
|
|
470
|
+
)
|
|
471
|
+
resp.raise_for_status()
|
|
472
|
+
run_async(_fail())
|
|
473
|
+
return
|
|
474
|
+
|
|
475
|
+
console.print(f" [dim]Branch: {branch}[/dim]")
|
|
476
|
+
|
|
477
|
+
prompt = _build_execute_prompt(task)
|
|
478
|
+
resume_id = task.get("session_id")
|
|
479
|
+
if resume_id:
|
|
480
|
+
console.print(f" [dim]Resuming session: {resume_id}[/dim]")
|
|
481
|
+
result = _run_claude(prompt, wt_path, EXECUTE_TOOLS, timeout, resume_session_id=resume_id)
|
|
482
|
+
|
|
483
|
+
output_text = result["output"]
|
|
484
|
+
|
|
485
|
+
async def _submit():
|
|
486
|
+
async with PmflowClient() as client:
|
|
487
|
+
resp = await client.http.post(
|
|
488
|
+
f"/workspaces/{slug}/agent-tasks/{task_id}/result",
|
|
489
|
+
json={
|
|
490
|
+
"status": "done" if result["ok"] else "failed",
|
|
491
|
+
"output": output_text[:50000],
|
|
492
|
+
"error": result.get("error"),
|
|
493
|
+
"branch": branch,
|
|
494
|
+
},
|
|
495
|
+
)
|
|
496
|
+
resp.raise_for_status()
|
|
497
|
+
|
|
498
|
+
run_async(_submit())
|
|
499
|
+
|
|
500
|
+
if result["ok"]:
|
|
501
|
+
console.print(f" [green]✓ Done[/green] — branch: [cyan]{branch}[/cyan]")
|
|
502
|
+
else:
|
|
503
|
+
console.print(f" [red]✗ Failed[/red]: {result.get('error', 'unknown error')}")
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Install/uninstall pmflow Claude Code skills
|
|
1
|
+
"""Install/uninstall pmflow Claude Code skills."""
|
|
2
2
|
|
|
3
3
|
import shutil
|
|
4
4
|
from pathlib import Path
|
|
@@ -11,26 +11,37 @@ from pmflow_cli.output import console
|
|
|
11
11
|
app = typer.Typer(help="Manage Claude Code skills")
|
|
12
12
|
|
|
13
13
|
SKILLS_SOURCE = Path(__file__).parent.parent / "skills"
|
|
14
|
+
|
|
15
|
+
# Claude Code target
|
|
14
16
|
COMMANDS_TARGET = Path.home() / ".claude" / "commands" / "pmflow"
|
|
17
|
+
|
|
15
18
|
SKILL_NAMES = ["plan-space", "plan-task", "prd", "prdfix", "snapshot-space"]
|
|
16
19
|
|
|
17
20
|
# Legacy install path (pre-v0.2), cleaned up on install/uninstall
|
|
18
21
|
_LEGACY_TARGET = Path.home() / ".claude" / "skills"
|
|
19
22
|
|
|
23
|
+
# Legacy Codex install path, cleaned up on install/uninstall
|
|
24
|
+
_CODEX_LEGACY_TARGET = Path.home() / ".codex" / "prompts"
|
|
25
|
+
|
|
20
26
|
|
|
21
27
|
def _clean_legacy():
|
|
22
|
-
"""Remove skills installed to
|
|
28
|
+
"""Remove skills installed to old locations."""
|
|
23
29
|
for name in SKILL_NAMES:
|
|
30
|
+
# Old ~/.claude/skills/ location
|
|
24
31
|
legacy = _LEGACY_TARGET / f"pmflow-{name}"
|
|
25
32
|
if legacy.exists():
|
|
26
33
|
shutil.rmtree(legacy)
|
|
34
|
+
# Old ~/.codex/prompts/ location
|
|
35
|
+
codex_legacy = _CODEX_LEGACY_TARGET / f"pmflow-{name}.md"
|
|
36
|
+
if codex_legacy.exists():
|
|
37
|
+
codex_legacy.unlink()
|
|
27
38
|
|
|
28
39
|
|
|
29
40
|
@app.command()
|
|
30
41
|
def install(
|
|
31
42
|
force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing skills"),
|
|
32
43
|
):
|
|
33
|
-
"""Install pmflow skills into
|
|
44
|
+
"""Install pmflow skills into Claude Code."""
|
|
34
45
|
if not SKILLS_SOURCE.exists():
|
|
35
46
|
console.print("[red]Error:[/red] Skills source directory not found. Package may be corrupted.")
|
|
36
47
|
raise typer.Exit(1)
|
|
@@ -38,19 +49,20 @@ def install(
|
|
|
38
49
|
_clean_legacy()
|
|
39
50
|
COMMANDS_TARGET.mkdir(parents=True, exist_ok=True)
|
|
40
51
|
|
|
41
|
-
installed = []
|
|
42
|
-
skipped = []
|
|
52
|
+
installed: list[str] = []
|
|
53
|
+
skipped: list[str] = []
|
|
43
54
|
|
|
44
55
|
for name in SKILL_NAMES:
|
|
45
56
|
src = SKILLS_SOURCE / name / "SKILL.md"
|
|
46
|
-
|
|
57
|
+
if not src.exists():
|
|
58
|
+
continue
|
|
47
59
|
|
|
60
|
+
dst = COMMANDS_TARGET / f"{name}.md"
|
|
48
61
|
if dst.exists() and not force:
|
|
49
62
|
skipped.append(name)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
installed.append(name)
|
|
63
|
+
else:
|
|
64
|
+
shutil.copy2(src, dst)
|
|
65
|
+
installed.append(name)
|
|
54
66
|
|
|
55
67
|
if installed:
|
|
56
68
|
console.print(f"[green]Installed {len(installed)} skill(s):[/green] {', '.join(installed)}")
|
|
@@ -59,22 +71,25 @@ def install(
|
|
|
59
71
|
console.print("Use [bold]--force[/bold] to overwrite.")
|
|
60
72
|
|
|
61
73
|
if installed:
|
|
62
|
-
console.print(
|
|
74
|
+
console.print(
|
|
75
|
+
"\n[dim]/pmflow:plan-space, /pmflow:plan-task, /pmflow:prd, /pmflow:prdfix, /pmflow:snapshot-space[/dim]"
|
|
76
|
+
)
|
|
63
77
|
|
|
64
78
|
|
|
65
79
|
@app.command()
|
|
66
80
|
def uninstall():
|
|
67
|
-
"""Remove pmflow skills from
|
|
81
|
+
"""Remove pmflow skills from Claude Code."""
|
|
68
82
|
_clean_legacy()
|
|
69
83
|
|
|
70
|
-
removed = []
|
|
84
|
+
removed: list[str] = []
|
|
85
|
+
|
|
71
86
|
for name in SKILL_NAMES:
|
|
72
87
|
dst = COMMANDS_TARGET / f"{name}.md"
|
|
73
88
|
if dst.exists():
|
|
74
89
|
dst.unlink()
|
|
75
90
|
removed.append(name)
|
|
76
91
|
|
|
77
|
-
# Remove
|
|
92
|
+
# Remove empty directory
|
|
78
93
|
if COMMANDS_TARGET.exists() and not any(COMMANDS_TARGET.iterdir()):
|
|
79
94
|
COMMANDS_TARGET.rmdir()
|
|
80
95
|
|
|
@@ -87,7 +102,7 @@ def uninstall():
|
|
|
87
102
|
@app.command(name="list")
|
|
88
103
|
def list_skills():
|
|
89
104
|
"""List pmflow skills and their install status."""
|
|
90
|
-
table = Table(title="pmflow
|
|
105
|
+
table = Table(title="pmflow Skills")
|
|
91
106
|
table.add_column("Skill", style="bold")
|
|
92
107
|
table.add_column("Command")
|
|
93
108
|
table.add_column("Status")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import typer
|
|
2
2
|
|
|
3
|
-
from pmflow_cli.commands import ai, auth, context, issue, skills, workspace
|
|
3
|
+
from pmflow_cli.commands import ai, auth, context, issue, runner, skills, workspace
|
|
4
4
|
|
|
5
5
|
app = typer.Typer(name="pmflow", help="pmflow - Project management for AI-assisted development")
|
|
6
6
|
|
|
@@ -10,6 +10,7 @@ app.add_typer(issue.app, name="issue", help="Issue management")
|
|
|
10
10
|
app.add_typer(context.app, name="ctx", help="Context (attachments/docs) management")
|
|
11
11
|
app.add_typer(ai.app, name="ai", help="AI-assisted PRD generation")
|
|
12
12
|
app.add_typer(skills.app, name="skills", help="Manage Claude Code skills")
|
|
13
|
+
app.add_typer(runner.app, name="runner", help="Agent Runner for automated task execution")
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
@app.command()
|
|
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
|
|
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
|