# Open Pr

> Open a pull request (Azure DevOps or GitHub) for the current branch, giving it a title and description that follow the CALLER's own conventions — learned at runtime from their past merged PRs — with a ticket link and a description of what changed and why. Use whenever someone wants to open / raise / put up a PR, finish a branch, or send changes for review on Azure DevOps or GitHub, even if they don't say "open-pr". It always shows the title and description for confirmation first and never creates the PR without explicit approval. Trigger phrases: "open a PR", "raise a PR", "create a pull request", "put this up for review", "PR this branch", "backport this to a release line", "open-pr", "/open-pr". Do NOT trigger for: reviewing or summarizing an existing PR; triaging or replying to PR comments (that is a separate resolve-pr-comments skill); completing / merging a PR; starting a branch; or plain git operations.

- Skill: `softwareone-platform/open-pr` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add softwareone-platform/open-pr`
- Raw SKILL.md: https://api.skillmd.com/api/skills/softwareone-platform/open-pr/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: softwareone-platform (https://skillmd.com/u/softwareone-platform)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/softwareone-platform/open-pr

---


# Open PR

Open a pull request for the current branch on the detected PR platform (Azure DevOps or GitHub). The title and description are drafted to match **the caller's own previous PRs**, read off the repo's own history — falling back to everyone's PRs, and then to no convention at all — rather than a fixed house style, then shown for confirmation. The PR is **never created without explicit approval** — opening a PR is an outward-facing action.

This skill is backend-agnostic: the platform-specific mechanics (create PR, dup-check, label, merged-PRs query, PR cross-reference link) live in **backend adapter reference docs** under `resources/backends/`, and the ticket-link mechanics live in **tracker adapter reference docs** under `resources/trackers/`. The skill body detects platform and tracker, loads the matching adapters, and follows their recipes — it holds **no `az`/`gh`-specific field parsing and no tracker-specific id/URL parsing** of its own. It opens a PR for the branch **as it is** — publishing it to the remote first where the remote does not have it, or does not yet have its newest commits, since the PR carries what the remote branch holds. It does not merge the base branch in, does not delete branches, and does not triage review comments (that is `resolve-pr-comments`).

## Step 0 — Detect platform + tracker, load adapters, announce

Before anything else, resolve which backend and tracker this run targets:

- **PR platform** — from `git remote get-url origin`. **Extract the host before matching anything, because the rules below are host rules and a remote is often not a URL.** Use `git remote get-url` rather than `git config remote.origin.url`, because it expands an `insteadOf` alias to the real URL and the raw config value may be an alias with no host in it at all. What comes back is still often not a tidy URL: `https://host/path` and `ssh://git@host/path` parse normally, the SCP-like `git@host:path` has no scheme and its `:` separates host from *path* rather than a port, a `https://org@host/…` form carries userinfo to strip, and a host may carry an explicit `:<port>` that is part of connecting but not part of the name to match. If no host can be extracted even so — a local path or a relative one — say so and ask, rather than feeding the string to the probes below as if it were a hostname.
  - host **is** `dev.azure.com` or **ends with** `.dev.azure.com`, or **ends with** `.visualstudio.com` → **Azure DevOps** (`resources/backends/azure-devops.md`). The `.dev.azure.com` suffix is load-bearing, not defensive: Azure DevOps's own SSH clone URL is `git@ssh.dev.azure.com:v3/<org>/<project>/<repo>`, so an equality-only rule rejects every SSH-cloned Azure DevOps repository — the legacy `vs-ssh.visualstudio.com` form keeps working under the suffix rule, which is exactly what makes the gap easy to miss.
  - host **is** `github.com`, or **ends with** `.github.com` → **GitHub** (`resources/backends/github.md`).
  - **Match the end of the host, never a substring** — `github.company.com` contains `github.com` and is not it.
  - Any other host may still be a **GitHub Enterprise** server, whose hostname is arbitrary and cannot be recognised by name. Ask `gh` instead of guessing: `gh auth status --hostname <host>` (with any `:<port>` stripped) exiting 0 means the user has configured that host, which identifies it as a GitHub-family server — use the GitHub adapter. **A non-zero exit here does not mean "not GitHub".** The same command is the GitHub adapter's tool precondition, so it also fails when `gh` is not installed, or when the host *is* an Enterprise server the user simply has not logged into or whose token has expired — and "which platform is this?" is the wrong question to put to someone whose real answer is `gh auth login --hostname <host>`. So when it exits non-zero, offer both readings: name the host, say `gh` could not confirm it, and ask whether it is a GitHub-family server needing `gh` auth or another platform entirely.
  - **When any of these asks cannot be reached** — running non-interactively, or inside a subagent that cannot prompt — do not guess a platform. Stop and print what you resolved, as a voiced limit, the same way Step 1 treats an unreachable target ask and Step 4 an unobtainable confirmation.
- **Issue tracker (precedence — first match wins):**
  1. **Infer** the type from an existing ticket ref — look for one in the invoking request, in an explicit ticket argument, and in the current branch name, since those are the only sources that exist before an adapter is loaded, and the adapter that would define a ticket ref's shape is the one this step is choosing. Match it **case-sensitively**: `[A-Z][A-Z0-9]+-\d+` → **Jira** (either platform). Case matters here more than it looks: read case-insensitively the same pattern swallows ordinary branch names (`patch-1`, `electron-41-eol`, `esbuild-0.28.2`) and invents a ticket key out of a slug, which then sends the run asking for a tracker instance that has nothing to do with the change; a bare numeric issue ref → **GitHub Issues** — but only on a **GitHub** remote (on Azure DevOps `#<n>` is a work-item link, not a GitHub issue, so a bare number does not imply GitHub Issues there).
  2. else **default only where the platform supplies the tracker**: a GitHub remote → **GitHub Issues**, which is same-repo and needs no configuration. An Azure DevOps remote gets **no default** — its native tracker is Azure Boards work items, no adapter ships for those, and assuming Jira instead would send the run asking for a Jira instance the team may not have. Ask which tracker to use, or produce no tracker line.
  3. No ticket at all → produce no tracker line.
  - Jira → `resources/trackers/jira.md`; GitHub Issues → `resources/trackers/github-issues.md`. The Jira adapter resolves its own base URL — `.claude/pr-lifecycle.json`, the Atlassian MCP, or a one-time prompt — so that lookup is not a step here.
- **Announce** the detected platform and tracker so a silent wrong guess is impossible.

**Resolve the plugin root first, and stop if you cannot.** Every adapter this skill reads lives under the plugin root, at `resources/backends/` and `resources/trackers/` — never under the skill directory, and never at a path containing `plugins/pr-lifecycle/`, which exists only in this marketplace's own source tree and not in an install. Resolve that root the way this Claude Code build exposes it (`${CLAUDE_PLUGIN_ROOT}` where available, otherwise the directory holding this skill's plugin manifest), and read one adapter to confirm the resolution before going further.

**If the adapters cannot be read, stop and say so — do not continue unadapted.** The whole platform-specific half of this skill lives in them: how a sample is obtained, how a duplicate is detected, how the pull request is created, how a ticket reference is built. Without them there is no sample to learn from and no create recipe, so proceeding produces either a wrong pull request or an invented one. Report which path failed and let the human point you at the plugin, the same way Step 4 prints a prepared draft rather than guessing when it cannot get a confirmation.

Then load the matching backend and tracker adapters and run the platform/tracker-specific parts of the steps below through them. The backend seam in this body is **selecting** the adapter and nothing else — recognising the remote string, plus the one `gh auth status --hostname` exit code above, which is a deliberate exception because a GitHub Enterprise host cannot be selected by name and no adapter can be loaded before the selection is made. **No field access beyond that**: past this point every platform value comes from an adapter recipe.

**Then discharge the loaded backend adapter's tool precondition, before Step 1 and before any other work.** Each backend adapter states what must be true of its platform tool and names the one read-only command that establishes it; run that command and read its exit status. Where it passes, say so in one line alongside the announcement above and continue. Where it fails, **stop here** — name the platform, give the cause and remedy as far as the adapter can separate them, and do not enter Step 1. **No draft can be printed at this point, and that is the one way this differs from the same failure caught later**: Step 3 has not run, so the Voiced limits' "print the prepared title and description" belongs to a tool failure that surfaces at or after Step 3, never to this one. Two things the adapter owns rather than this body: which command, and how far a failure can be attributed. And on an unrecognised host the `gh auth status --hostname` probe above has already discharged this, so do not run it twice. What this check buys is **when** the failure is found, not what it prevents: without it the first platform call in the whole flow is Step 2's duplicate check, so a tool that was never set up surfaces after four preconditions have run and at the one point where the prescribed remedy cannot be carried out, rather than before anything has been invested.

## Step 1 — Detect the target branch

- **Git cannot tell you which branch this one was cut from, so do not try to work it out.** There is no field for it: before the first push there is no upstream, and after `git push -u` the upstream is this branch's *own* remote copy; the reflog says only `branch: Created from HEAD`, names no branch, is local, and expires. Reconstructing it from topology fails on ordinary cases rather than exotic ones — a branch already on the remote scores as its own parent, and merging the base in before opening the PR reverses the ranking. What follows is a default plus two ways for a human to name something else, not a derivation.
- **Take the target from the request or the session whenever either names it.** "backport this to v2-maintenance"; the branch this one was checked out from earlier in this same session; a branch name that spells the line out. Each is the human telling you the target. Note which end a signal points at: a cherry-pick names the commit's **source**, which on a backport is the branch you are porting *from* — the target is in the checkout, not the pick.
- **Otherwise default to the repo's own default branch, and ask the remote for it.** `git ls-remote --symref origin HEAD` reports it directly, is read-only, and cannot be stale. The local `refs/remotes/origin/HEAD` is a weaker source: whether `git fetch` maintains it depends on the client's `remote.<name>.followRemoteHEAD` setting, so it may be absent, stale, or silently updated mid-run — use it only as a cross-check. **Do not guess a name.** A default branch is often neither `main` nor `master` (a version number and a word like `dev` are both common), so if the remote does not answer, ask rather than trying candidates — and note that asking a platform API whether a branch exists can answer *yes* for a renamed one, because the rename redirect follows. It is the least surprising answer because it is where the repository itself points, and because both platforms fall back to it when no base is given — though on GitHub a per-branch `gh-merge-base` git config is consulted ahead of it, which is one more reason to name the target explicitly on every create rather than relying on any platform default.
- **Resolve the default branch even when the target is named.** It is not only the fallback target: Step 3's sample is read relative to it on at least one platform, so a run that took the target from the request — every backport, and every `resolve-issue` invocation, which always names the base — still needs it. Resolve it the same way, and if the remote does not answer, say the sample may be drawn from the wrong branch rather than guessing a name for it.
- **Announce the target and where it came from** — `target: <branch> (from your request)`, `target: <branch> (repo default)` — and **ask rather than guess** whenever the request names two candidates, or names none and the remote could not tell you the default. Everything downstream is silent about a wrong target: Step 2's ahead-count still passes against the wrong base, and the PR simply opens against it.
- **When that ask cannot be reached** — running non-interactively, or inside a subagent that cannot prompt — do **not** fall through to the default as though it had been chosen. Say `target: <branch> (repo default, unconfirmed — could not ask)` and treat it as a voiced limit, the same way Step 4 treats a confirmation it cannot obtain.
- **A branch name never changes the format.** A maintenance or release line is an ordinary target. Step 3 reads its sample from the repo's previous pull requests, not from the target, so the target decides where the PR lands and nothing else.

## Step 2 — Preconditions (stop and report if any fails)

Run these before drafting anything, because each is a common first-run blocker:

- **On a named branch.** `git symbolic-ref --quiet HEAD` must succeed. A detached HEAD has no branch to publish and none to name as the pull request's source, and without this check the failure lands inside the platform's create call — after the human has confirmed.
- **Source and target are not the same branch.** Compare the branch just resolved against the target Step 1 announced. Neither platform will open a pull request from a branch to itself, so this is a stop — and it needs to be its own check, ahead of the count below, because nothing downstream reports it correctly. Where the branch is ahead of its own remote copy — the ordinary state of a repository whose commits reach its default branch by direct push — the count below **passes**, and the run goes on to publish that branch at Step 4 on a confirmation the human gave for a draft. Where it is instead level with its remote copy the count stops the run, but it says "nothing to PR", which sends the human to look at their commits rather than at the branch they are standing on. So say the two are the same, name where Step 1 took the target from, and stop. Do not create or switch to a branch, and do not fall back to a different target — both are the human's call, and this skill opens a pull request for the branch as it is.
- **Has commits to PR.** The branch must be ahead of the target: `git fetch`, then `git rev-list --count origin/<target>..HEAD` must be greater than 0. **Check the exit status too**: an unknown target exits 128 and prints no count, which reads as a zero and stops the run reporting "nothing to PR" when the real fault is the target. Report an unresolvable target as such and ask for the right one.
- **On the remote *and* current — publish if either is missing.** The PR contains what the **remote** branch holds, not what is local, so check both halves. Does `origin/<branch>` exist (`git ls-remote --heads origin <branch>`, which answers from the remote rather than from a tracking ref that may be stale)? And where it does, is it behind HEAD (`git rev-list --count origin/<branch>..HEAD` greater than 0)? Neither is a stop: after confirmation, open-pr publishes the branch (`git push -u origin <branch>`) as part of the create in Step 4, which covers both. **The second half is the one that fails silently** — a branch pushed earlier and since committed to opens a PR missing its newest commits while the description describes them, and nothing else in this flow looks at the source branch again. Record which of the two it was and carry it into the Step 4 confirmation. (Running non-interactively it does not push and does not create — see Step 4.)
- **Uncommitted work will not be in the PR.** `git status --porcelain` — a non-empty result means those changes stay behind, however the description reads. This is **not** a stop: unrelated dirty files are ordinary. Carry the list into the Step 4 confirmation and name it there, so the human approves a PR knowing what is missing from it instead of finding out afterwards.
- **No duplicate.** Check for an existing open PR **from this source branch** via the **backend adapter's dup-check recipe** — by source alone, never narrowed to the target, since a target that defaulted wrongly hides the very PR this check exists to find. The adapter returns one entry per open PR from this source, and because this body may not read platform fields, each entry is normalized to exactly three values it is obliged to supply: **`url`** (or the id, where that is what identifies it to a human), **`target`** — the plain branch name, with any `refs/heads/` prefix already stripped, since the platform stores a full ref and comparing that to the bare name you are about to open against silently never matches — and **`from_fork`**, true when the head is a branch of a fork rather than of this repository. Judge on those. One to the **same** `target` is a duplicate: **stop and point the user to it** — do not open a second PR, and do not modify the existing one here. One to a **different** `target` is not automatically a duplicate — the same branch can legitimately go to both a release line and the default branch — so show it, name the target it goes to, and ask before creating another. An entry with `from_fork` true is somebody else's branch that merely shares a name: show it, say so, and do not treat it as a duplicate of yours. **Where that ask cannot be reached** (non-interactive, or a subagent that cannot prompt), do not create a second PR on your own judgement — stop and report what was found, as a voiced limit.

## Step 3 — Learn from previous pull requests, then draft

**This skill ships no title or description shape.** How a pull request looks in this repo is read off ones that already merged, in this order — take the first that yields a **usable sample**:

1. **the caller's own previous PRs** — their style is the one to match, since it is their pull request;
2. **anyone's**, when rung 1 is not usable.

**A usable sample is one where the shape you take is a pattern rather than a single pull request repeated** — a judgement about what is in front of you, not a count to reach. No threshold is set here, deliberately: nobody in this chain has the data to make a number mean anything, and asking the caller for one only moves an arbitrary choice onto someone with less to go on. The announcement below already bars invented precision, so do not invent a cut-off either — carry the count you actually got into that announcement and let the reader judge it. One pull request is never a pattern. Where both rungs are thin, say so and impose no shape.

If neither yields anything, no convention is known: draft plainly (below) and say so.

**The backend adapter supplies the sample.** Where it comes from differs by platform, for reasons the adapter documents — so ask the adapter rather than assuming git holds it.

**Identify the caller loosely, and expect the match to be imprecise.** One person appears under more than one display name in a single repo (`Surname, First` alongside `First Surname`), and the name the platform recorded is routinely not the one in `git config`. So match a distinctive *token* — a surname, a handle — rather than a whole name or address, and match it case-insensitively. Three cautions. On some platforms that filter is a regular expression, so a token containing `[`, `.`, `+` or `(` either aborts the read outright or silently widens it: pick a token without them rather than escaping. On some it is matched against the author's **address as well as the name**, so a token that also occurs in an email domain or local part quietly matches every colleague at that domain — which is rung 2 wearing rung 1's label. And if the sample plainly contains more than one person, widen to the second probe rather than learning a stranger's style — and say rung 2 in the announcement below, because a sample that widened is no longer "your previous PRs" and reporting it as such is the one error that announcement exists to prevent.

**Read the change from the committed range, `git diff origin/<target>...HEAD`, and from nowhere else** — that range is exactly what the pull request will contain, so a description drawn from the working tree describes something the reviewer cannot see. Uncommitted work is named separately at the Step 4 confirmation, per Step 2; it is a warning about what is missing, never material for the description.

### Presentation is learned — all of it

Reproduce what the sample does, and introduce nothing it does not do.

- **The title's shape, whatever that is** — a ticket reference in brackets, bare with a colon, a Conventional Commits type, a component or subsystem tag in brackets, or no prefix at all. Where the sampled titles carry no ticket reference, do not introduce one — and look for where the sample *does* put one, since a repo that keeps ticket references out of its titles often has a dedicated slot for them in the body.
- **The body's shape** — prose, bullets, a heading structure, a filled-in template, a bare reference to the PR a change was ported from. Where the sampled bodies share a heading structure, reproduce it, dropping only what is not content: the template's own instruction comments, and any heading left blank across most of the sample. Never copy a checkbox's state, and never carry over a provenance footer some earlier run left behind.
- **The language the sample is written in.** A repo whose pull requests are written in the maintainers' language gets one written in that language.

### Substance is learned only where the sample has some

One question decides it: **does that body, on its own, tell a reviewer what changed — without following a link?**

- **Where it does, match it, length included.** A complete two-line description is a convention, not a shortfall. Do not inflate it, and do not promote plain prose to headings or bullets because those look more thorough.
- **Where it does not** — an empty body, or a bare cross-reference with no account of the change — the sample has nothing to teach here, and matching it would make this skill pointless: writing the description is the work it exists to do. So write one that passes the test, in the presentation the sample taught, and **say that you overrode the sample on substance**.

Substance is the one element the sample gets no vote on. Keep it proportionate all the same: a one-line change earns a sentence, not a manufactured list.

**Where the change has a shape, draw it.** Some changes are a sentence — a value, a threshold, a rename. Others are structural: work moves between components, a sequence reorders, a control path is replaced, one thing becomes two. For those a small diagram carries in a glance what a paragraph carries slowly, and readers skim a long description however well it is written. So include one when the change has that kind of shape, and leave it out when it does not — a diagram of a one-line change is noise, and putting one on every description destroys the signal that a diagram is worth stopping for.

This is the one element the sample cannot teach in either direction. Almost no repository's pull requests carry a diagram, so their absence is not evidence that one would not help here — it sits on the substance side for the same reason the description does: it is how the reader understands the change, not how the repository dresses it up. Keep it high-level, showing the shape of the change rather than redrawing the diff, and take the form that renders on this platform from the **backend adapter**. **Where a change does have that shape, the diagram is where the effort belongs, and the prose's job shrinks to what a diagram cannot carry** — the why, the constraint, the consequence a reader has to act on. A reader takes a shape in at a glance and skims a long paragraph, so length spent on prose the diagram already carries makes the description worse rather than more thorough. This is an allocation rule, not a licence to draw one every time: the gate above still decides whether the change has a shape at all.

**Announce the probe, the sample size, and the shape you took** — plainly, and without invented precision: `learned from 12 of your previous PRs: bare ticket reference with a colon, two-line prose body`. Where you overrode the sample's substance, say so on the same line. State only what you actually observed; a fraction nobody counted is worse than no fraction, because it invites the reader not to check. This announcement is the only place a convention that is really one person's habit becomes visible.

**An empty sample and a failed query are different, and only one means "no history".** A query can come back empty because the filter was wrong — a mis-picked identity token, an unknown branch — and on some platforms that looks identical to genuine absence. Confirm the query ran before reading emptiness as history; when it did not, name what failed and degrade with an announcement, never silently.

### When nothing was learned

**Title** — a concise one-line summary of the change, introducing no prefix, no bracket, no marker, and no casing rule. Where a ticket exists, include its reference in the form the **tracker adapter** yields, wherever it reads naturally. Two or more *different* ticket ids means ask which, rather than silently taking the first.

**Description** — the ticket link when there is a ticket (the **tracker adapter** builds it), then what the change does and why, in plain prose as short as the change allows. Describe intent, omit file and symbol names, and do not paste the diff. That is the content any description owes a reviewer; it is not a layout, and with no sample there is no layout to apply.

### AI-provenance markers — opt-in, off by default, on every path

This rule governs **both** paths above, learned and unlearned alike; it is not part of "when nothing was learned", and the adapters cite it by this heading.

Add the footer (a `---` divider, then `🤖 _Drafted with Claude Code._`) and the `ai-assisted` label of Step 4 **only when the invoking request explicitly asks to mark the pull request as AI-assisted**. Absent that, add neither, and say which way it went so a dropped marker is never silent. When opted in, the footer is the last element and learning never drops or reorders it.

On the learned path the sample can push the other way: a caller who opted in on a previous PR has a 🤖 footer sitting in their own history, and "reproduce what the sample does" would carry it into a run that did **not** opt in. The opt-in decides this, never the sample — strip such a footer from what you reproduce, the same way the body-shape rule already refuses a provenance footer an earlier run left behind.


## Step 4 — Present, confirm, create

Show the drafted title and description and let the user edit them, and name any uncommitted changes Step 2 found, since those will not be in the PR. **Say whether confirming will publish the branch, and which of Step 2's two cases applies** — the branch is not on the remote at all, or it is there without its newest commits. Step 2 is already told to carry that forward; this is where it lands. A confirmation that does not say it covers a push is a confirmation for something narrower than what follows it. Where the draft carries a diagram, add one line saying it can be dropped or redrawn — it is the element most likely to be wanted differently, and the reader cannot edit what they do not know is optional.

**Measure the description against the platform's own limit before creating — the draft, and again after any edit.** A platform that **rejects** an over-long description rather than truncating it — Azure DevOps is one, and its adapter states the figure — fails the create after the human has already approved, and an unedited draft can be over the limit just as easily as an edited one, so measuring only the edit leaves the commoner case unmeasured. The **backend adapter's create recipe** states the limit for its platform, or states that none was established. Then act on the result rather than only announcing it: under the limit, proceed; over it, **do not create** — say by how much, offer to trim, and re-measure what comes back. **When you trim, cut prose first: the diagram is the last thing to go, not the first.** Step 3 puts it on the substance side because it is how the reader understands the change, so cutting prose the diagram already carries improves the description, while cutting the diagram leaves the reader with more words and less shape. Where the adapter records no established limit, say that the description was not measured — that is an unmeasured risk, not a confirmed absence of one, and it is the honest thing to put in front of the human before they approve. Then:

- **Standalone:** create only after the user explicitly confirms. Where Step 2 found the source branch missing from the remote **or behind HEAD**, publish it first with `git push -u origin <branch>` — explicitly, and never by leaving the branch for the platform tool to publish as a side effect of the create, which puts the push outside this flow's own failure branch below and, on GitHub, can offer to fork the repository. Then create the PR via the **backend adapter's create recipe** — writing the full description to a temp file and passing it as the adapter's body-file flag, never as an inline string. The adapter documents the platform's file-encoding traps (e.g. Azure DevOps's `az.cmd` cp1252/UTF-8 `@<file>` quirks) and — **only when provenance is opted in (Step 3)** — how the `ai-assisted` label is applied (Azure DevOps creates the tag inline via `--labels`; GitHub adds it *after* create with `gh pr edit --add-label`, because `gh pr create --label` aborts if the label does not exist). When opted in the label is best-effort either way (see Voiced limits); by default no label is applied. Return the PR URL.
- **The publish is rejected** — `git push -u origin <branch>` exits non-zero. **Do not create the PR, and do not force-push.** Read the cause off git's own message rather than assuming one: a **non-fast-forward** rejection means the remote branch has diverged (typically after a rebase or an amend), while a **pre-receive / protected-branch / permission / secret-scanning** refusal means nothing diverged at all — they share an exit code and only the message separates them, and prescribing the wrong remedy for the second is worse than prescribing none. Only for the non-fast-forward case, report the divergence in both directions — `git fetch origin <branch>` first, since the tracking ref may be stale or absent and `git rev-list --left-right --count origin/<branch>...HEAD` then exits 128 rather than printing a count — and name both remedies: integrating the remote commits and re-running, or a force-push, which the safety rails allow only on an explicit request. For every other cause, report git's message and stop without proposing a remedy. Either way, say plainly that this is a **push failure, not a declined confirmation** — they leave the same trace (no branch, no PR) and a caller that cannot tell them apart records the wrong reason for the stop.
- **Cannot get a confirmation** (running non-interactively, or inside a subagent that cannot prompt): **do not push the branch and do not create the PR.** Print the prepared title and description for the user to create manually, and say so. Do not use an open-in-browser flag (e.g. Azure DevOps `--open`) in a non-interactive context. When provenance was opted in, the printed description already carries the footer; tell the user to add the `ai-assisted` label when they create the PR. By default (no opt-in) neither marker is printed or mentioned.

The invariant: **never publish the branch or open a PR without an explicit human confirmation** — both are outward-facing.

## Safety rails

- Never resolve merge conflicts automatically — stop and report them.
- Never delete branches.
- Never force-push without an explicit request.

## Voiced limits

- If the detected platform's PR tool is missing or unauthenticated (see each backend adapter's precondition — e.g. `az` + the `azure-devops` extension for Azure DevOps, `gh` authed for GitHub), say so and print the prepared title and description for manual creation — do not fail silently. **Step 0 checks this before any other work, and a failure there stops the run with no draft**, because Step 3 has not run; the printed draft belongs to a tool failure that first surfaces at or after Step 3.
- Target detection is best-effort by design, not by omission: git cannot say which branch this one was cut from, so a target nobody named defaults to the repo's default branch. Name the target you used on every run, and ask whenever it is not obvious — a wrong target is silent everywhere else in this flow.
- Learned convention is best-effort, and the two halves fail separately: a sample can teach a title shape while teaching nothing about a body. Say which half you learned rather than implying both.
- Provenance markers are **off by default** — no footer and no label unless the invoking request explicitly opts in (Step 3). When opted in, the `ai-assisted` label is best-effort: if the platform rejects it (an org that disallows ad-hoc PR tags on Azure DevOps, or a label that does not yet exist on GitHub), drop the label, create the PR with the footer alone (the opted-in marker), and say so — the backend adapter documents the per-platform behaviour.

