# Branch Derivation

How the engine decides which branch a dispatch is going to push to.
CLAUDE.md → Branch Naming holds the one-line summary; the structured
vs. loose-extractor rules and the canonical PR-fix incident live here.

> Source of truth: `engine/shared.js`
> (`deriveWorkItemBranchName`, `extractStructuredWorkItemPrRef`,
> `extractWorkItemPrRef`, `copyWorkItemPrFields`),
> `engine.js#getWorkItemPrRef` + `getStructuredWorkItemPrRef`,
> `dashboard.js POST /api/work-items`. Last verified: 2026-06-09.

## Engine-side fallback

`shared.deriveWorkItemBranchName(item, config)` is the single helper used
by every engine.js fallback site and `dashboard.js POST /api/work-items`.
It returns `work/<wi-id>` (sanitized). Agents authoring branches by hand
follow the long-form `user/<loginname>/<wi-id>-<slug>` convention
taught in `playbooks/shared-rules.md`. The engine fallback is
intentionally short because the engine has no operator login context.

Three rule overrides that skip the fallback:

| Condition | Branch used |
|-----------|-------------|
| `item.branch` is set | Use as-is |
| Item targets an existing PR (see below) | Reuse the PR's source branch |
| Item is part of a shared-branch plan | Use `feature_branch` from the PRD |

## PR-fix exception (issue #2999 / W-mpx6i5kh000ac040)

`shared.extractWorkItemPrRef(item)` is the single source of truth for
"does this WI target an existing PR?" — used by `dashboard.js#getWorkItemPrRef`
and `engine.js#getWorkItemPrRef`. It walks, in order:

1. Structured fields: `targetPr`, `pr_id`, `prUrl`, `prNumber`,
   `pullRequest`, `sourcePr`, `pr`, `prId`.
2. `references[*].url`.
3. `meta.pr_followup.parent_pr_url`.
4. **(loose only)** Regex-scans description and title for PR URLs or
   canonical `github:owner/repo#N` / `ado:org/proj/repo#N` ids.

When a ref is detected, `copyWorkItemPrFields` stamps
`targetPr` / `pr_id` / `prNumber`, `item.branch` is unset, and
`discoverFromWorkItems` reuses the PR's source branch.

**Type gate (W-mqbaby2a000pa8ee).** The loose description/title scan
(step 4 above) only stamps on `type: "fix"` WIs. Non-fix WIs
(implement / explore / test / review / …) only get stamped from
structured fields (steps 1-3). Without this gate, an implement WI
whose description merely mentions an existing PR in prose
("Class bug surfaced today on pull request 130") would silently
get `targetPr` / `pr_id` / `prNumber` stamped, route through the
PR-fix dispatch path, fail the PR-branch lookup, and stick in
`_pendingReason: null` forever. Structured intent
(`targetPr` / `prUrl` / `references[].url` / etc.) still stamps on
every type — only the loose regex scan is gated.

## Structured-vs-loose split (W-mq18ec6h000p7b87)

The PR-ref extractor has **two** variants — pick the right one for the
call site:

| Helper | What it walks | Used by | Why |
|--------|---------------|---------|-----|
| `shared.extractStructuredWorkItemPrRef(item)` | Structured fields + `references[*].url` + `meta.pr_followup.parent_pr_url`. **No** description / title scan. | `engine.js#getStructuredWorkItemPrRef` → `pr_not_found` dispatch gate. | Gating blocks dispatch and MUST require explicit operator intent. A description like "see PR #3015 for context" must NOT trip the gate. |
| `shared.extractWorkItemPrRef(item)` | Structured walk + last-resort description / title scan. | `engine.js#getWorkItemPrRef` (branch derivation, prompt PR context, `resolveWorkItemPrRecord`); `dashboard.js#getWorkItemPrRef` (POST `/api/work-items` create-time `targetPr` stamping, **type-gated on `fix` — W-mqbaby2a000pa8ee**). | Callers downgrade gracefully when no PR record matches; stamp path preserves the operator UX of pasting a PR URL into description prose and getting `targetPr` auto-stamped on `type: "fix"` WIs. |

**Rule of thumb: gate uses structured-only; stamp uses loose.**
Stamping is best-effort and reversible; gating blocks dispatch and should
require explicit operator intent.

## Canonical bad incident — P-c8a1d2e3 (2026-06-05)

A refactor WI with no structured PR fields whose description merely
mentioned PR #3015 / PR #3012 as cross-references got stuck in
`_pendingReason: 'pr_not_found'` forever because the gate called the
loose extractor. Fix: split the helpers so the gate calls
`extractStructuredWorkItemPrRef` and the stamp path calls
`extractWorkItemPrRef`.
