diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c40d23..25fee36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,10 +25,7 @@ jobs: run: pip install -r requirements-dev.txt ruff - name: Lint - # Lint the whole repo; format-check only the test suite (the plugin's - # existing source predates a ruff-format pass — normalizing it is a - # separate cleanup, out of scope for the test add). - run: ruff check . && ruff format --check tests/ + run: ruff check . && ruff format --check . - name: Test run: pytest -q diff --git a/__init__.py b/__init__.py index 61f5174..091727e 100644 --- a/__init__.py +++ b/__init__.py @@ -40,6 +40,7 @@ def register(registry) -> None: # (plugin_id, prefix) de-dupe keeps both. try: from .api import build_data_router, build_router + registry.register_router(build_router(cfg), prefix="/plugins/project_board") registry.register_router(build_data_router(cfg), prefix="/api/plugins/project_board") except Exception: # noqa: BLE001 — API is best-effort @@ -48,6 +49,7 @@ def register(registry) -> None: # Background orchestration loop (off unless project_board.loop_enabled). try: from .loop import BoardLoop + loop = BoardLoop(cfg) registry.register_surface(loop.start, stop=loop.stop, name="project-board-loop") except Exception: # noqa: BLE001 — loop is best-effort; the API still serves @@ -61,21 +63,26 @@ def register(registry) -> None: # skill that turns an idea into the docs tree + the board (per-epic human gate). try: from .subagents import ANTAGONIST_CONFIG, DECOMPOSE_CONFIG + registry.register_subagent(DECOMPOSE_CONFIG) registry.register_subagent(ANTAGONIST_CONFIG) registry.register_skill_dir("skills") except Exception: # noqa: BLE001 — planning layer is best-effort log.exception("[project_board] registering planning subagents/skill failed") - log.info("[project_board] registered board API + loop + tools + planning (coder=%s reviewer=%s)", - cfg.get("coder", "proto"), cfg.get("reviewer", "quinn")) + log.info( + "[project_board] registered board API + loop + tools + planning (coder=%s reviewer=%s)", + cfg.get("coder", "proto"), + cfg.get("reviewer", "quinn"), + ) def _board_tools(cfg: dict): from .store import BoardError, get_store - store_kw = dict(db=cfg.get("db_path") or None, repo=cfg.get("repo", "."), - base_branch=cfg.get("base_branch", "main")) + store_kw = dict( + db=cfg.get("db_path") or None, repo=cfg.get("repo", "."), base_branch=cfg.get("base_branch", "main") + ) @tool def board_create_epic(title: str, description: str = "") -> str: @@ -87,9 +94,17 @@ def board_create_epic(title: str, description: str = "") -> str: return f"Error: {exc}" @tool - def board_create_feature(title: str, spec: str = "", acceptance_criteria: str = "", - files_to_modify: str = "", design: str = "", parent: str = "", - priority: int = 2, difficulty: str = "", depends_on: str = "") -> str: + def board_create_feature( + title: str, + spec: str = "", + acceptance_criteria: str = "", + files_to_modify: str = "", + design: str = "", + parent: str = "", + priority: int = 2, + difficulty: str = "", + depends_on: str = "", + ) -> str: """Create a board feature (a bead; starts in `backlog`). To pass the Ready gate a feature needs a self-sufficient `spec`, testable `acceptance_criteria`, AND `files_to_modify` (comma-separated paths to create/modify — vague tasks @@ -100,9 +115,16 @@ def board_create_feature(title: str, spec: str = "", acceptance_criteria: str = deps = [d.strip() for d in depends_on.split(",") if d.strip()] files = [p.strip() for p in files_to_modify.replace("\n", ",").split(",") if p.strip()] f = get_store(**store_kw).create_feature( - title, spec=spec, acceptance_criteria=acceptance_criteria, design=design, - files_to_modify=files, parent=parent, priority=priority, - difficulty=difficulty, depends_on=deps) + title, + spec=spec, + acceptance_criteria=acceptance_criteria, + design=design, + files_to_modify=files, + parent=parent, + priority=priority, + difficulty=difficulty, + depends_on=deps, + ) return json.dumps({"id": f["id"], "state": f["board_state"], "title": f["title"]}) except BoardError as exc: return f"Error: {exc}" @@ -122,11 +144,20 @@ def board_list(state: str = "") -> str: """List board features, optionally filtered by board `state` (backlog/ready/ in_progress/in_review/done/blocked). Priority order.""" feats = get_store(**store_kw).list_features(state=state or None) - return json.dumps([ - {"id": f["id"], "title": f["title"], "state": f["board_state"], - "blocked": f["blocked"], "dag_blocked": f.get("dag_blocked", False), - "pr_url": f["pr_url"], "priority": f["priority"], "difficulty": f["difficulty"]} - for f in feats - ]) + return json.dumps( + [ + { + "id": f["id"], + "title": f["title"], + "state": f["board_state"], + "blocked": f["blocked"], + "dag_blocked": f.get("dag_blocked", False), + "pr_url": f["pr_url"], + "priority": f["priority"], + "difficulty": f["difficulty"], + } + for f in feats + ] + ) return [board_create_epic, board_create_feature, board_mark_ready, board_list] diff --git a/api.py b/api.py index 59303d7..ed2ef26 100644 --- a/api.py +++ b/api.py @@ -10,10 +10,8 @@ drivable here, headlessly. The ``/webhook/pr`` endpoint is the SINGLE external Done edge: a merged-PR event -sets ``done`` and nothing else does (invariant #2). Poll is the fallback. - -STUB note: webhook signature verification (GitHub HMAC) is TODO — wire it before -exposing the endpoint publicly. Review the payload→record_merge mapping below. +sets ``done`` and nothing else does (invariant #2). The raw body is HMAC-verified +against ``X-Hub-Signature-256`` whenever a ``webhook_secret`` is configured. """ from __future__ import annotations @@ -46,14 +44,19 @@ def build_router(cfg: dict): @router.get("/board", response_class=HTMLResponse) async def _board(): return HTMLResponse(BOARD_PAGE) - store_kw = dict(db=(cfg or {}).get("db_path") or None, repo=(cfg or {}).get("repo", "."), - base_branch=(cfg or {}).get("base_branch", "main")) + + store_kw = dict( + db=(cfg or {}).get("db_path") or None, + repo=(cfg or {}).get("repo", "."), + base_branch=(cfg or {}).get("base_branch", "main"), + ) escalate_on = escalation_enabled(cfg) worktrees_root = (cfg or {}).get("worktrees_root", ".worktrees") # GitHub webhook secret (HMAC-SHA256). From config or env; blank ⇒ verification # disabled (dev only) — a warning fires per unsigned request. - webhook_secret = str((cfg or {}).get("webhook_secret") or - os.environ.get("PROJECT_BOARD_WEBHOOK_SECRET", "")).strip() + webhook_secret = str( + (cfg or {}).get("webhook_secret") or os.environ.get("PROJECT_BOARD_WEBHOOK_SECRET", "") + ).strip() def store(): return get_store(**store_kw) @@ -69,8 +72,7 @@ def _guard(fn): # PUBLIC-of-necessity surface: the /board page (an iframe page-load can't # carry a bearer) and the CI-infra edges — /webhook/pr (GitHub signs with # HMAC, not the operator bearer) and /features/{fid}/ci (posted by CI - # runners; carries bounded semantics, its own auth story tracked with the - # webhook-signature TODO above). + # runners; a CI-infra edge with bounded semantics). @router.post("/features/{fid}/ci") async def _ci(fid: str, body: dict = Body(...)): @@ -88,14 +90,16 @@ async def _ci(fid: str, body: dict = Body(...)): def _handle(): s = store() if not escalate_on: - return {"requeued": False, "escalated": False, - "feature": s.bounce_ci_fail(fid, reason)} + return {"requeued": False, "escalated": False, "feature": s.bounce_ci_fail(fid, reason)} nxt = s.escalate(fid, f"ci-fail: {reason}" if reason else "ci-fail") if nxt is None: - return {"requeued": False, "escalated": True, "exhausted": True, - "feature": s.block_from_review(fid, f"ci-fail: {reason}")} - return {"requeued": True, "escalated": True, "next_tier": nxt, - "feature": s.requeue(fid)} + return { + "requeued": False, + "escalated": True, + "exhausted": True, + "feature": s.block_from_review(fid, f"ci-fail: {reason}"), + } + return {"requeued": True, "escalated": True, "next_tier": nxt, "feature": s.requeue(fid)} return _guard(_handle) @@ -113,8 +117,10 @@ async def _webhook_pr(request: Request): if not hmac.compare_digest(expected, sig): raise HTTPException(401, "invalid webhook signature") else: - log.warning("[project_board] webhook signature NOT verified — set " - "project_board.webhook_secret (or PROJECT_BOARD_WEBHOOK_SECRET)") + log.warning( + "[project_board] webhook signature NOT verified — set " + "project_board.webhook_secret (or PROJECT_BOARD_WEBHOOK_SECRET)" + ) try: body = json.loads(raw or b"{}") except json.JSONDecodeError: @@ -131,6 +137,7 @@ async def _webhook_pr(request: Request): # Reap the feature's worktree now that it's merged → done (stop accumulation). try: from . import worktree + repo = store_kw["repo"] wt = os.path.join(repo, worktrees_root, f"feat-{f['id']}") await worktree.remove_worktree(repo, wt, f"feat/{f['id']}") @@ -151,8 +158,11 @@ def build_data_router(cfg: dict): from fastapi import APIRouter, Body, HTTPException router = APIRouter() - store_kw = dict(db=(cfg or {}).get("db_path") or None, repo=(cfg or {}).get("repo", "."), - base_branch=(cfg or {}).get("base_branch", "main")) + store_kw = dict( + db=(cfg or {}).get("db_path") or None, + repo=(cfg or {}).get("repo", "."), + base_branch=(cfg or {}).get("base_branch", "main"), + ) def store(): return get_store(**store_kw) @@ -170,8 +180,11 @@ async def _create_epic(body: dict = Body(...)): @router.post("/milestones") async def _create_milestone(body: dict = Body(...)): - return _guard(lambda: store().create_milestone( - body.get("title", ""), body.get("epic_id", ""), body.get("description", ""))) + return _guard( + lambda: store().create_milestone( + body.get("title", ""), body.get("epic_id", ""), body.get("description", "") + ) + ) # ── features ────────────────────────────────────────────────────────────── @router.get("/features") @@ -193,8 +206,9 @@ async def _create_feature(body: dict = Body(...)): async def _dep(fid: str, body: dict = Body(...)): """Add a `blocks` edge: `fid` waits for `depends_on` to be merged→done. (Foundation gating is just a blocks-edge on the foundation feature.)""" - return _guard(lambda: (store().add_dependency(fid, str(body.get("depends_on", ""))), - store().get_feature(fid))[1]) + return _guard( + lambda: (store().add_dependency(fid, str(body.get("depends_on", ""))), store().get_feature(fid))[1] + ) # ── transitions ─────────────────────────────────────────────────────────── @router.post("/features/{fid}/ready") diff --git a/board_view.py b/board_view.py index 740e865..682a4dd 100644 --- a/board_view.py +++ b/board_view.py @@ -1,4 +1,4 @@ -"""Board console view (ADR 0026, D5) — the deferred-until-now UI. +"""Board console view (ADR 0026, D5) — the Kanban/list board UI. A self-contained page served at ``/plugins/project_board/board`` (by the API router in api.py — see __init__.register) that renders the board two ways (Kanban diff --git a/loop.py b/loop.py index f421076..11c3ea5 100644 --- a/loop.py +++ b/loop.py @@ -13,11 +13,9 @@ merge webhook ▼ CI fail ▼ any failure ▼ done in_progress (bounce) blocked (flag + reason) -STUBBED for review: CI status + merge arrive via webhook/poll (api.py), not here; -``open_pr`` is sketched (worktree.py). The loop's claim/dispatch/teardown/error -paths below are the thing to review before hardening. Concurrency is capped at 1 -for the first cut (token + merge-integration cost); the cap is where parallelism -lands later. +CI status + merge arrive out-of-band via the board API (``api.py``), not here. +Concurrency is capped at 1 (token + merge-integration cost); the cap is where +parallelism lands later. """ from __future__ import annotations @@ -51,9 +49,11 @@ def __init__(self, cfg: dict): # and we never write redundant tier/attempt labels. self.coders = {str(k): str(v) for k, v in (self.cfg.get("coders") or {}).items()} self.escalation_on = escalation_enabled(self.cfg) - self._store_kw = dict(db=self.cfg.get("db_path") or None, - repo=self.cfg.get("repo", "."), - base_branch=self.cfg.get("base_branch", "main")) + self._store_kw = dict( + db=self.cfg.get("db_path") or None, + repo=self.cfg.get("repo", "."), + base_branch=self.cfg.get("base_branch", "main"), + ) self._task: asyncio.Task | None = None self._stop = asyncio.Event() # The in-flight build's worktree, so shutdown can reap it (a cancel mid-drive @@ -70,8 +70,12 @@ def start(self): log.info("[project_board] loop disabled (project_board.loop_enabled=false) — board API still serves") return None self._task = asyncio.create_task(self._run(), name="project-board-loop") - log.info("[project_board] loop started (coder=%s reviewer=%s every %ss)", - self.coder_name, self.reviewer_name, self.interval) + log.info( + "[project_board] loop started (coder=%s reviewer=%s every %ss)", + self.coder_name, + self.reviewer_name, + self.interval, + ) return self._task async def stop(self): @@ -139,16 +143,16 @@ async def _drive(self, feature: dict): return # Fresh worktree per attempt (a failed attempt may leave partial work). wt, branch = await worktree.create_worktree(repo, base, fid, self.root) - self._active = (repo, wt, branch) # track for shutdown reaping + self._active = (repo, wt, branch) # track for shutdown reaping try: result = await worktree.dispatch_coder(coder, wt, prompt) # reaps subprocess - pr_url = await worktree.open_pr(wt, branch, base=base, title=title, - body=(result or "")[:4000]) + pr_url = await worktree.open_pr(wt, branch, base=base, title=title, body=(result or "")[:4000]) except (worktree.NoChangesError, worktree.WorktreeError) as exc: # Capability failure (coder errored / no diff) → escalate when a # ladder exists; an infra failure (push/gh) is not escalable → block. - capability = isinstance(exc, worktree.NoChangesError) or \ - str(exc).startswith("coder dispatch failed") + capability = isinstance(exc, worktree.NoChangesError) or str(exc).startswith( + "coder dispatch failed" + ) if self.escalation_on and capability: nxt = store.escalate(fid, str(exc)[:200]) if nxt: @@ -169,7 +173,7 @@ async def _drive(self, feature: dict): await self._request_review(fid, pr_url) # Keep the worktree (a CI-fail bounce re-dispatches); reaping happens # on a terminal block above, and the coder subprocess is already reaped. - self._active = None # built OK — not an interrupted build to reap + self._active = None # built OK — not an interrupted build to reap return except BoardError as exc: log.warning("[project_board] %s blocked (board): %s", fid, exc) @@ -191,6 +195,7 @@ async def _request_review(self, fid: str, pr_url: str): log.info("[project_board] no reviewer %r configured — skipping review dispatch", self.reviewer_name) return from plugins.delegates.adapters import ADAPTERS + try: msg = f"Please review this PR for correctness and acceptance: {pr_url}" await ADAPTERS["a2a"].dispatch(reviewer, msg) @@ -206,6 +211,7 @@ def _resolve_delegate(self, name: str, expect_type: str): try: from plugins.delegates.registry import DelegateRegistry from plugins.delegates.store import merged_delegates + d = DelegateRegistry(merged_delegates()).get(name) except Exception: # noqa: BLE001 — delegates plugin may be disabled return None @@ -218,8 +224,9 @@ def _build_prompt(self, feature: dict) -> str: passive 'implement this feature' + a vague spec makes a coder produce nothing; naming the files + a direct 'make the edits now' makes it act.""" files = feature.get("files_to_modify") or [] - files_block = "\n".join(f"- {f}" for f in files) if files else \ - "(none listed — create the files the task requires)" + files_block = ( + "\n".join(f"- {f}" for f in files) if files else "(none listed — create the files the task requires)" + ) design = feature.get("design", "") design_block = f"\n## Design / context\n{design}\n" if design.strip() else "" return ( diff --git a/store.py b/store.py index 202e672..08ad22a 100644 --- a/store.py +++ b/store.py @@ -49,8 +49,7 @@ LABEL_BLOCKED = "blocked" # difficulty → initial model tier (the escalation ladder's first rung, D10). -DIFFICULTY_TIER = {"small": "fast", "medium": "smart", "large": "reasoning", - "architectural": "reasoning"} +DIFFICULTY_TIER = {"small": "fast", "medium": "smart", "large": "reasoning", "architectural": "reasoning"} TIER_LADDER = ["fast", "smart", "reasoning"] @@ -62,8 +61,7 @@ class BeadsBoard: """Wraps the `br` CLI. One process-wide instance (the loop, API, and tools share it). `br` auto-discovers `.beads/*.db`; pass ``db`` to pin a workspace.""" - def __init__(self, db: str | None = None, actor: str = "agent", - repo: str = ".", base_branch: str = "main"): + def __init__(self, db: str | None = None, actor: str = "agent", repo: str = ".", base_branch: str = "main"): if not shutil.which(BR): raise BoardError(f"beads CLI {BR!r} not on PATH — install beads or set BR_BIN") self.db = db or None @@ -90,8 +88,16 @@ def _run(self, *args: str, want_json: bool = False): except json.JSONDecodeError as exc: raise BoardError(f"`br {args[0]}` returned non-JSON: {exc} :: {out[:200]}") - def _create(self, title: str, *, itype: str, parent: str = "", priority: int = 2, - description: str = "", external_ref: str = "") -> str: + def _create( + self, + title: str, + *, + itype: str, + parent: str = "", + priority: int = 2, + description: str = "", + external_ref: str = "", + ) -> str: args = ["create", title, "--type", itype, "-p", str(priority), "--silent"] if parent: args += ["--parent", parent] @@ -109,18 +115,26 @@ def create_epic(self, title: str, description: str = "") -> dict: return self.get_feature(self._create(title, itype="epic", description=description)) def create_milestone(self, title: str, epic_id: str, description: str = "") -> dict: - return self.get_feature(self._create(title, itype="milestone", parent=epic_id, - description=description)) - - def create_feature(self, title: str, *, spec: str = "", acceptance_criteria: str = "", - design: str = "", files_to_modify=(), parent: str = "", priority: int = 2, - difficulty: str = "", depends_on=()) -> dict: + return self.get_feature(self._create(title, itype="milestone", parent=epic_id, description=description)) + + def create_feature( + self, + title: str, + *, + spec: str = "", + acceptance_criteria: str = "", + design: str = "", + files_to_modify=(), + parent: str = "", + priority: int = 2, + difficulty: str = "", + depends_on=(), + ) -> dict: """Create a feature bead (starts in `backlog`). Provide a self-sufficient spec + acceptance_criteria + the explicit files to create/modify so it can pass the Ready gate (ProtoMaker's spec-quality discipline — vague tasks make a coder produce nothing).""" - fid = self._create(title, itype="feature", parent=parent, priority=priority, - description=spec) + fid = self._create(title, itype="feature", parent=parent, priority=priority, description=spec) upd = [] if acceptance_criteria: upd += ["--acceptance-criteria", acceptance_criteria] @@ -166,8 +180,7 @@ def claim_next_ready(self, assignee: str = "") -> dict | None: `in_progress`. Returns None if nothing is ready. (`br ready` is priority- ordered; we filter `feature` in Python to dodge the --type+--label quirk.)""" ready = self._run("ready", "--label", LABEL_READY, want_json=True) or [] - feats = [b for b in ready if b.get("issue_type") == "feature" - and LABEL_BLOCKED not in (b.get("labels") or [])] + feats = [b for b in ready if b.get("issue_type") == "feature" and LABEL_BLOCKED not in (b.get("labels") or [])] if not feats: return None fid = feats[0]["id"] @@ -204,8 +217,18 @@ def requeue(self, fid: str) -> dict: self._require(fid) # Clear the assignee too — without it `br update --claim` on the re-pull # fails ("already assigned to ") and the feature can't be re-dispatched. - self._run("update", fid, "--status", "open", "--assignee", "", - "--add-label", LABEL_READY, "--remove-label", LABEL_IN_REVIEW) + self._run( + "update", + fid, + "--status", + "open", + "--assignee", + "", + "--add-label", + LABEL_READY, + "--remove-label", + LABEL_IN_REVIEW, + ) return self.get_feature(fid) def block_from_review(self, fid: str, reason: str) -> dict: @@ -295,9 +318,23 @@ def get_feature(self, fid: str) -> dict | None: def list_features(self, state: str | None = None) -> list[dict]: # All statuses — `br list` defaults to open/in_progress, but the board view # needs `closed` features too (that's the Done column). - rows = self._run("list", "--type", "feature", "--status", "open", "--status", - "in_progress", "--status", "closed", "--status", "deferred", - want_json=True) or [] + rows = ( + self._run( + "list", + "--type", + "feature", + "--status", + "open", + "--status", + "in_progress", + "--status", + "closed", + "--status", + "deferred", + want_json=True, + ) + or [] + ) out = [self._project(r) for r in rows] # `br list` omits dependencies, so mark dag_blocked by cross-referencing the # puller: a `ready` feature the puller WON'T claim is blocked by an open dep. @@ -351,15 +388,17 @@ def _project(self, bead: dict) -> dict: """A `br` bead → the board's feature view (stable shape for the loop/API).""" labels = bead.get("labels") or [] diff = next((l.split(":", 1)[1] for l in labels if l.startswith("diff:")), "") - attempts = sorted(int(l.split(":", 1)[1]) for l in labels if l.startswith("attempt:") - and l.split(":", 1)[1].isdigit()) + attempts = sorted( + int(l.split(":", 1)[1]) for l in labels if l.startswith("attempt:") and l.split(":", 1)[1].isdigit() + ) # `dag_blocked`: marked `ready` but a `blocks` dependency is still open, so # the puller won't claim it. Only `br show` carries dependencies (`br list` # doesn't); list_features patches this by cross-referencing the puller. state = self.board_state(bead) dag_blocked = state == "ready" and any( d.get("dependency_type") == "blocks" and d.get("status") != "closed" - for d in (bead.get("dependencies") or [])) + for d in (bead.get("dependencies") or []) + ) return { "id": bead.get("id"), "title": bead.get("title", ""), diff --git a/worktree.py b/worktree.py index 309c882..2b3657f 100644 --- a/worktree.py +++ b/worktree.py @@ -36,8 +36,12 @@ class NoChangesError(WorktreeError): async def _git(repo: str, *args: str, timeout: float = 60) -> tuple[int, str, str]: """Run a git command in ``repo``; return (rc, stdout, stderr).""" proc = await asyncio.create_subprocess_exec( - "git", "-C", repo, *args, - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + "git", + "-C", + repo, + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) try: out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout) @@ -110,8 +114,11 @@ async def dispatch_coder(coder, worktree: str, prompt: str, *, timeout: float | async def _gh(*args: str, cwd: str, timeout: float = 60) -> tuple[int, str, str]: proc = await asyncio.create_subprocess_exec( - "gh", *args, cwd=cwd, - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + "gh", + *args, + cwd=cwd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, ) try: out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout) @@ -133,8 +140,7 @@ async def commit_worktree(worktree: str, message: str) -> None: raise WorktreeError(f"commit failed: {(e or o).strip()[:200]}") -async def open_pr(worktree: str, branch: str, *, base: str = "main", - title: str, body: str = "") -> str: +async def open_pr(worktree: str, branch: str, *, base: str = "main", title: str, body: str = "") -> str: """Commit + push the worktree's branch and open (or reuse) a PR; return its URL. Operates **inside the worktree** (the confinement boundary). Raises @@ -155,13 +161,13 @@ async def open_pr(worktree: str, branch: str, *, base: str = "main", raise WorktreeError(f"git push failed: {err.strip()[:300]}") # 3. Open the PR — or recover the existing one (re-dispatch case). - rc, out, err = await _gh("pr", "create", "--head", branch, "--base", base, - "--title", title, "--body", body or title, cwd=worktree) + rc, out, err = await _gh( + "pr", "create", "--head", branch, "--base", base, "--title", title, "--body", body or title, cwd=worktree + ) if rc == 0: return out.strip() if "already exists" in err.lower() or "already exists" in out.lower(): - vrc, vout, _ve = await _gh("pr", "view", branch, "--json", "url", - "--jq", ".url", cwd=worktree) + vrc, vout, _ve = await _gh("pr", "view", branch, "--json", "url", "--jq", ".url", cwd=worktree) if vrc == 0 and vout.strip(): return vout.strip() raise WorktreeError(f"gh pr create failed: {err.strip()[:300]}")