# PR-Native Delivery Mode (`git.merge_strategy: pr`) — Design & Implementation Plan

**Date:** 2026-07-04
**Status:** Proposed
**Motivation:** `docs/sota-gap-analysis-2026-07.md` → Strategic Bets → A. Every major commercial agent delivers as a reviewable PR; claw-forge squash-merges to main with zero human checkpoint — the single biggest product gap vs. the market. This plan adds `merge_strategy: pr` (push feature branch + `gh pr create --draft`) and a feedback loop that feeds PR review comments and red CI logs back into the existing retry-preamble machinery.

---

## 1. Current Architecture (verified against the code)

- **Delivery path today:** `orchestrator/task_handler.py:task_handler` (post-success `finally` block, ~L1934–1961) calls `GitOps.apply_on_completion` (`git/__init__.py:251`), which — when `merge_strategy == "auto"` — squash-merges via `git/merge.py:squash_merge` and, on success, calls `remove_worktree()` + `delete_branch(force=True)` (merge.py:404–405, 423–425). The result is PATCHed back by `task_handler.py:_set_merged_after_merge` (L456), setting `merged_to_target_branch=True` + `merge_commit_sha`, or re-pending on failure up to `git.merge_retry_attempts`.
- **Merge-gating:** `state/scheduler.py` — a dependency is satisfied only when `status == "completed" AND merged_to_target_branch` (`get_ready_tasks` L61–87, `are_dependencies_satisfied` L122–145, `get_execution_order` L180–184). `task_handler` PATCHes `merged_to_target_branch=False` at start only when `git_merge_strategy == "auto"` (task_handler.py:736–744).
- **`merge_strategy: manual` precedent:** `apply_on_completion` simply skips the merge arm; `merged_to_target_branch` is never flipped False, so the DB default `True` (models.py:146–148) means **dependents flow freely and branches/worktrees are preserved**. Cleanup degrades to scan+report (`run_cli.py:1037`, `1124–1144` via `scan_orphaned_branches`).
- **Retry/resume machinery:** on any retry, the agent gets a resume preamble built from `error_message`, `branch_commit_subjects()`, and `HANDOFF.md` (task_handler.py:994–1113); the merge-recovery path additionally injects a structured "Merge Conflict Recovery (CRITICAL)" section keyed off `error_message` prefix `merge_retry_` (L1001–1004). Re-dispatch is DB-status-authoritative (`run_cli.py:select_redispatch_pending` L236–259).
- **Cleanup layers that would destroy PR branches today:**
  - `git/cleanup.py:decide_action` (L85–122): `completed` + has-commits → **salvage** (squash to main) — exactly what a PR-backed branch must not get. Gated to `merge_strategy == "auto"` at `run_cli.py:1039–1043`, so PR mode accidentally avoids it, but loses smart cleanup entirely.
  - `git/branching.py:merge_orphaned_worktrees` (L237) and `prune_worktrees` (L350) — salvage/force-remove sweeps.
- **Push/remote primitives already exist:** `git/commits.py:push_to_remote` (L103) and `has_remote` (L129); `git.push_after_merge` wiring in `git/__init__.py:322–351` is the template for calling them from `apply_on_completion`.
- **Schema evolution:** no migration framework; `state/service.py:_ensure_task_columns` (L323–381) introspects `PRAGMA table_info(tasks)` and issues idempotent `ALTER TABLE … ADD COLUMN` at startup. New columns also need: `UpdateTaskRequest` (service.py:240–250), the three serialization dicts (service.py:45, ~1454, ~1561), `state/client.py:46` TaskNode mapping, and `scheduler.TaskNode`.
- **Background loop precedents:** `orchestrator/checkpointing.py:periodic_checkpoint_loop` (per-task asyncio.Task) and `run_cli.py:heartbeat_loop` (per-run asyncio.Task, L1327–1333) — templates for an in-run PR poller. `git/merge_cli.py` is the template for a standalone Typer command module.

---

## 2. Design Decisions

### D1 — Merge-gating in PR mode: **optimistic scheduling + parent-branch enrichment** (recommended)

Three options considered:

| Option | Mechanics | Verdict |
|---|---|---|
| (a) Strict: poll for actual GitHub merge | `merged_to_target_branch` stays False until PR merges; poller flips it | Semantically honest, but the whole DAG stalls on human review latency — a 30-feature build becomes 30 sequential review round-trips. Defeats the parallel harness. Keep as opt-in `git.pr.gating: strict` for protected-main/enterprise workflows. |
| (b) Pure optimistic: PR-opened counts as merged | Flip gate on PR creation, dependents branch from target | DAG flows, but dependents cut from `target_branch` **lack the parent's code** (nothing landed locally) — agents re-implement or fail tests. Unacceptable alone. |
| (c) Stacked: dependents branch off parent's feature branch | Dependent worktrees contain parent work | Right idea, but `create_worktree` (branching.py:125) is single-base; multi-parent deps need N-way merge, and GitHub squash-merges make pure branch-stacking rebase-hostile. |

**Recommendation: (b)+(c) hybrid — "optimistic with enrichment".**
- On successful PR creation, PATCH `pr_state="open"` and satisfy the scheduling gate. Rather than lying in `merged_to_target_branch` (which the run summary at `run_cli.py:186–232`, the training exporter's `merged_to_target_branch=1` filters, and smart-cleanup all read as "content is on local target"), **extend the gate**: a dep is satisfied when `status=="completed" AND (merged_to_target_branch OR pr_state in ("open","merged"))`. Touches `scheduler.py` (3 sites), `TaskNode` (new `pr_state` field), `state/client.py:46`, and the dispatch-time gate. `merged_to_target_branch` keeps its truthful meaning.
- **Enrichment:** when creating a dependent's worktree in PR mode, after the normal `sync_worktree_with_target`, merge in each parent's still-live feature branch (parents with `pr_state=="open"`). This is one extra `git merge --no-verify --no-edit <parent-branch>` per unmerged parent inside `_create_and_sync_worktree` (task_handler.py:259–290), reusing the exact conflict semantics of `sync_worktree_with_target` (merge.py:169). Conflicts follow the existing `resume_conflict` / merge-recovery paths unchanged.
- **PR base selection:** exactly one unmerged parent → open the PR with `--base <parent-branch>` (stacked PR; GitHub auto-retargets to the parent's base when the parent branch is deleted at merge). Zero or multiple unmerged parents → base = `git.pr.base` (defaults to `git.target_branch`). Multi-parent PR diffs will show parent content until parents merge — documented, acceptable edge.
- **Reconciliation:** when the poller observes a PR merged on GitHub, it PATCHes `pr_state="merged"`, fetches and fast-forwards the local target branch, then removes the worktree and deletes the local branch (the same post-merge cleanup `squash_merge` does today).

### D2 — Where PR creation hooks in

- **New module `claw_forge/git/pr.py`** (pure, subprocess-based like `git/repo.py:_run_git`): `preflight_pr_mode(project_dir, remote) -> PreflightResult` (remote exists / gh on PATH / `gh auth status` / remote is GitHub), `push_feature_branch(project_dir, branch, remote)` (uses `--force-with-lease` — the harness owns `feat/*`), `create_or_get_pr(project_dir, branch, base, *, draft, title, body) -> {"number", "url"}` (idempotent: `gh pr list --head <branch> --json number,url,state` first, create only if absent), `fetch_pr_status(...)` and `fetch_pr_feedback(...)` for Phase 5.
- **New arm in `GitOps.apply_on_completion`** (`git/__init__.py:295–352`), sibling to the `merge_strategy == "auto"` branch: checkpoint (existing), then push + create PR (no target-branch mutation, so no `exclusive_section()` needed) — run it via `asyncio.to_thread`. Return a result dict `{"merged": False, "pr_opened": True/False, "pr_number", "pr_url", "error"}`. `git/merge.py:squash_merge` stays untouched (it is also consumed by `boundaries/apply.py:116` and `git/cleanup.py:530`, which must keep auto semantics).
- **Result recording:** new `task_handler.py:_record_pr_after_completion` (sibling of `_set_merged_after_merge`, L456): success → PATCH `pr_number`, `pr_url`, `pr_state="open"` (leaving `merged_to_target_branch=False`); failure → reuse the re-pend pattern with `error_message="pr_create_failed_<N>: …"` bounded by `git.merge_retry_attempts` (the resume preamble already keys generic failures off `error_message`). `task_handler` L736–744 changes to also flip `merged_to_target_branch=False` (and `pr_state="none"`) when strategy is `pr`.
- **Worktree/branch lifecycle:** on PR-opened, **preserve branch and worktree** (the babysit loop resumes there; `create_worktree`'s re-link path at branching.py:161–174 recovers if the dir is pruned). Optionally run `shed_build_artifacts` (branching.py:211) to cap disk. Cleanup happens only at `pr_state="merged"`/`"closed"` reconciliation.

### D3 — Cleanup must learn about PR-backed branches

- `git/cleanup.py:decide_action` gains PR awareness: task with `pr_state in ("open",)` → **preserve** always (never salvage, never remove); `pr_state == "merged"` → **remove** (local branch + worktree; content is on the remote base). Extend `smart_cleanup_worktrees`'s task dicts (already the full state-service rows) — no signature change needed beyond reading `t.get("pr_state")`.
- `run_cli.py:1039–1043`: extend the smart-cleanup gate from `git_merge_strategy == "auto"` to `in ("auto", "pr")`, with the PR-aware matrix; the legacy `merge_orphaned_worktrees` salvage arm (L1111–1123) must **not** run in PR mode.
- `git/cli.py` (`worktrees prune`) and `branching.py:merge_orphaned_worktrees`/`prune_worktrees`: pass PR-backed slugs into the existing `skip_slugs` mechanism (both functions already accept it — L242, L353), sourced from the DB (`pr_state="open"`), mirroring how prune already skips `status='running'` slugs.

### D4 — CI-fix / review-comment iteration loop

Two delivery vehicles sharing one core:

1. **In-run poller (dispatcher-integrated), Phase 5a:** a per-run `asyncio.Task` in `run_cli.py` (pattern: `heartbeat_loop`, L1327) — `pr_watch_loop(state_base, session_id, project_path, cfg)`. Every `git.pr.poll_interval_seconds` it lists session tasks with `pr_state="open"` and calls `git/pr.py:fetch_pr_status` (`gh pr view <n> --json state,mergeStateStatus,reviewDecision,statusCheckRollup`) + `fetch_pr_feedback` (`gh pr checks`, `gh api .../comments` since last-seen timestamp, `gh run view --log-failed` tail for red checks). Decisions (pure function `evaluate_pr_feedback` for testability):
   - PR merged → reconcile (PATCH `pr_state="merged"`, ff local target, cleanup worktree/branch).
   - PR closed unmerged → PATCH `pr_state="closed"`, `status="failed"`, `error_message="pr_closed: …"`.
   - Red CI or `CHANGES_REQUESTED` or new actionable comments, and `pr_fix_count + 1 < git.pr.fix_attempts` → PATCH `status="pending"`, `pr_fix_count++`, `error_message="pr_feedback_<N>: <digest>"`. The wave loop's `select_redispatch_pending` (run_cli.py:236) then re-dispatches it; nothing else needed.
   - `mergeStateStatus == "DIRTY"` (conflicts with base) → route into the **existing** autonomous merge-recovery: same re-pend but with `error_message="merge_retry_<N>: pr base conflict"`, so the pre-dispatch sync + `leave_conflicts=True` + "Merge Conflict Recovery" preamble (task_handler.py:1046–1106) fire unchanged.
2. **Standalone `claw-forge pr babysit`, Phase 5b:** human review latency usually outlives the run. New `git/pr_cli.py` subapp (`pr status|open|babysit`), registered like `merge_cli`. `babysit` runs the same poll/evaluate core in a loop and, when it re-pends tasks, spins the existing dispatcher path (reuse `run`'s wave-loop via a thin entry, or simply print "N task(s) re-armed — starting fix wave" and invoke the dispatcher with the re-pended nodes). `pr status` is read-only; `pr open --task <id>` retries a failed PR creation.

**Preamble mapping:** add a "PR Feedback" section to the resume-preamble builder keyed off `error_message.startswith("pr_feedback_")` — structured like the merge-recovery section: review comments (file:line quotes), failing check names + log tails, and instructions ("address each comment; push happens automatically on completion; do not open a new PR"). On the fix attempt's completion, the `pr` arm of `apply_on_completion` is naturally idempotent: push (`--force-with-lease`) updates the same PR; `create_or_get_pr` finds the existing one. **Future SDK session resume:** when agent session IDs are persisted (planned elsewhere), `pr_feedback` retries can pass the prior session ID to `run_agent()` for a true conversation resume; the preamble remains the fallback — the design deliberately keeps the feedback digest in `error_message` so both channels work.

### D5 — Config, state, API/UI

```yaml
git:
  merge_strategy: pr           # auto | manual | pr
  pr:
    remote: origin
    base: ""                   # default: git.target_branch
    draft: true
    gating: optimistic         # optimistic | strict
    poll_interval_seconds: 120
    fix_attempts: 3            # bounded pr_feedback retries per task
    include_ci_logs: true      # attach `gh run view --log-failed` tails
```

Task columns (all via `_ensure_task_columns` ALTER handling — **no migration framework**): `pr_number INTEGER`, `pr_url VARCHAR(512)`, `pr_state VARCHAR(16) DEFAULT 'none'`, `pr_fix_count INTEGER NOT NULL DEFAULT 0`. Exposed through `UpdateTaskRequest`, all three task-serialization dicts, `state/client.py`, `scheduler.TaskNode`, `exporter.py` passthrough, and the UI (`ui/src/types.ts:Feature` + PR-link badge on `FeatureCard.tsx` / `TaskDetailModal.tsx` — an anchor with `pr_url`, state-colored: draft/open/merged/closed).

### D6 — Failure modes

| Failure | Handling |
|---|---|
| No remote configured | Startup preflight (`preflight_pr_mode` called from `run_cli` right after git bootstrap, before any dispatch): hard error with remediation ("add a remote or set merge_strategy: auto"). Uses `has_remote` (commits.py:129). |
| `gh` missing / unauthenticated | Same preflight: `shutil.which("gh")` + `gh auth status --hostname <host>`; fail fast at startup (mirrors the fatal-auth drain philosophy — don't cascade the DAG into per-task failures). |
| Remote not GitHub | Preflight parses `git remote get-url`; error suggesting `merge_strategy: manual`. |
| Push rejected (non-FF) | `--force-with-lease` first (harness-owned branches); still rejected (branch protection on `feat/*`, permissions) → re-pend via `pr_create_failed_<N>` up to cap, then preserve-and-warn like `merge_failed` today. |
| `gh pr create` races / PR already exists | `create_or_get_pr` is get-or-create; never duplicate PRs. |
| PR conflicts with base | Poller maps `mergeStateStatus: DIRTY` → existing merge-recovery loop (D4). |
| gh rate limits / network flakes in poller | Best-effort: log at warning, skip cycle; the poller must never fail tasks on its own transport errors (same liveness rule as leak_watch). |
| Mid-run reviewer merges the PR while a fix agent is running | Reconciler skips tasks with `status="running"`; ff-and-cleanup deferred to the next poll cycle after the task settles. |

---

## 3. Phased Implementation

### Phase 1 — State model + config surface (foundation, no behavior change)
- `state/models.py`: add `pr_number`, `pr_url`, `pr_state` (default `"none"`, server_default `'none'`), `pr_fix_count` to `Task`.
- `state/service.py`: ALTERs in `_ensure_task_columns` (L323); fields in `UpdateTaskRequest` (L240), the PATCH handler (~L1431), and serialization dicts (L45, ~L1454, ~L1561).
- `state/client.py:46`: map `pr_state` into `TaskNode`; `state/scheduler.py:TaskNode`: add `pr_state: str = "none"`.
- `config.py`: `git.pr` block in the scaffolded default config + validation of `merge_strategy in ("auto","manual","pr")`.
- `orchestrator/run_cli.py` (~L780) + `task_handler.py:RunContext` (L159–168): thread `git_pr_*` knobs.
- Tests: column-migration idempotency (existing `_ensure_task_columns` test pattern), PATCH round-trip, TaskNode mapping.

### Phase 2 — `git/pr.py` + preflight
- New module: `preflight_pr_mode`, `push_feature_branch`, `create_or_get_pr`, `fetch_pr_status`, `fetch_pr_feedback`, plus pure parsers for `gh` JSON. All subprocess calls mocked in tests (coverage gate is 90% branch — keep decision logic in pure functions).
- `run_cli.py`: call preflight at startup when `merge_strategy == "pr"`; abort with actionable message on failure.

### Phase 3 — PR creation on task success
- `git/__init__.py:apply_on_completion`: `merge_strategy == "pr"` arm — checkpoint → push → create/get PR → return pr-result dict; preserve worktree+branch; optional `shed_build_artifacts`.
- `task_handler.py`: `_record_pr_after_completion` (PATCH pr fields / bounded `pr_create_failed_<N>` re-pend); start-of-task PATCH (L736–744) also covers `pr` strategy; call site at L1950 branches on strategy.
- Tests: success path PATCHes, idempotent re-create, push-rejected re-pend, worktree preserved.

### Phase 4 — Scheduling gate + dependent enrichment + cleanup safety
- `scheduler.py`: gate becomes `completed AND (merged_to_target_branch OR pr_state in ("open","merged"))` in `get_ready_tasks`, `are_dependencies_satisfied`, `get_execution_order`.
- `task_handler.py:_create_and_sync_worktree`: after target sync, merge each `pr_state=="open"` parent branch (parents resolved via a session-tasks fetch); conflicts reuse `resume_conflict`/recovery paths. PR base selection (single unmerged parent → stacked) computed here and passed through to Phase 3's arm.
- `git/cleanup.py:decide_action` + `smart_cleanup_worktrees`: PR-aware matrix (open→preserve, merged/closed-reconciled→remove); `run_cli.py:1039` gate extended to `("auto","pr")`; `merge_orphaned_worktrees` never runs in PR mode; `git/cli.py` prune populates `skip_slugs` from `pr_state="open"` tasks.
- `run_cli.py` summary (L186–232): count `pr_state="open"` tasks as "🔀 In review (PR open): N" instead of "completed but un-merged".
- Tests: DAG flows with PR-opened parents; dependent worktree contains parent code; smart cleanup never salvages an open-PR branch.

### Phase 5 — Feedback loop
- 5a. `orchestrator/pr_watch.py`: `evaluate_pr_feedback` (pure) + `pr_watch_loop`; wired as a background task in `run_cli.py` beside `heartbeat_loop`; reconcile-on-merge (ff local target under `git_ops.exclusive_section()`, cleanup worktree/branch, PATCH `pr_state="merged"`).
- 5b. `git/pr_cli.py`: `claw-forge pr status|open|babysit`; registered in `cli.py`. `babysit` = poll loop + fix-wave dispatch of re-pended tasks.
- `task_handler.py` resume-preamble builder (L994–1113): add the "PR Feedback" section keyed on `pr_feedback_` prefix.
- Tests: feedback→re-pend bounded by `fix_attempts`; DIRTY→merge-recovery routing; merged→reconciliation; preamble content.

### Phase 6 — UI + docs
- `ui/src/types.ts` (`Feature` gains `pr_url`, `pr_state`, `pr_number`), `FeatureCard.tsx` PR badge/link, `TaskDetailModal.tsx` detail row; WS payloads already carry full task dicts.
- Docs: `CLAUDE.md` (merge-gating convention note + new strategy), `docs/commands.md` (`pr` subapp), `claw-forge.yaml` scaffold comments, gap-analysis follow-up note. Surface merged-rate/gate-pass-rate counters in `state/metrics.py` (`claw_forge_tasks{pr_state}`) as the market-comparison metrics called out in the strategic bet.

Rough sizing: Phases 1–3 are the incremental core (~2–3 days), Phase 4 is the correctness-critical middle (~2 days, most test surface), Phase 5 is the differentiator (~2–3 days), Phase 6 polish (~1 day).

---

## 4. Risks / open questions
- **Stacked-PR retarget after GitHub squash-merge** leaves content-identical-but-SHA-different parent commits in child branches; merges stay clean (3-way identical-change resolution) but child PR diffs are noisy until the enrichment merge of the updated base — the poller's reconcile step should merge the refreshed local target into open dependent branches (reuses `sync_worktree_with_target`) and push, which collapses the diff.
- **`gating: strict`** is trivially implementable (poller flips `merged_to_target_branch=True` on merge; no scheduler change needed) — ship it in Phase 5 as the conservative option.
- **`boundaries/apply.py`** keeps direct `squash_merge` (its serial refactor loop is out of scope for PR mode v1).
