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.
Files changed (29) hide show
  1. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/.gitignore +3 -0
  2. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/PKG-INFO +2 -2
  3. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/pyproject.toml +2 -2
  4. pmflow_cli-0.1.4/src/pmflow_cli/commands/runner.py +503 -0
  5. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/commands/skills.py +30 -15
  6. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/main.py +2 -1
  7. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/.claude-plugin/plugin.json +0 -0
  8. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/README.md +0 -0
  9. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/skills/plan-space/SKILL.md +0 -0
  10. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/skills/plan-task/SKILL.md +0 -0
  11. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/skills/prd/SKILL.md +0 -0
  12. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/skills/prdfix/SKILL.md +0 -0
  13. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/skills/snapshot-space/SKILL.md +0 -0
  14. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/__init__.py +0 -0
  15. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/client.py +0 -0
  16. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/commands/__init__.py +0 -0
  17. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/commands/ai.py +0 -0
  18. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/commands/auth.py +0 -0
  19. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/commands/context.py +0 -0
  20. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/commands/issue.py +0 -0
  21. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/commands/workspace.py +0 -0
  22. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/config.py +0 -0
  23. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/output.py +0 -0
  24. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/skills/plan-space/SKILL.md +0 -0
  25. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/skills/plan-task/SKILL.md +0 -0
  26. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/skills/prd/SKILL.md +0 -0
  27. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/skills/prdfix/SKILL.md +0 -0
  28. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/skills/snapshot-space/SKILL.md +0 -0
  29. {pmflow_cli-0.1.2 → pmflow_cli-0.1.4}/src/pmflow_cli/utils.py +0 -0
@@ -27,5 +27,8 @@ out/
27
27
  # Claude Code (installed via `pmflow skill install`)
28
28
  .claude/commands/
29
29
 
30
+ # Agent Runner worktrees
31
+ .pmflow-worktrees/
32
+
30
33
  # uv
31
34
  uv.lock
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pmflow-cli
3
- Version: 0.1.2
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.2
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.2"
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.2",
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 to ~/.claude/commands/pmflow/."""
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 the old ~/.claude/skills/ location."""
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 ~/.claude/commands/pmflow/ for Claude Code."""
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
- dst = COMMANDS_TARGET / f"{name}.md"
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
- continue
51
-
52
- shutil.copy2(src, dst)
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("\n[dim]Skills are now available as /pmflow:plan-space, /pmflow:plan-task, /pmflow:prd, /pmflow:prdfix, /pmflow:snapshot-space in Claude Code.[/dim]")
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 ~/.claude/commands/pmflow/."""
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 the pmflow directory if empty
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 Claude Code Skills")
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