# Feature: Base-Branch Evidence

<!-- toc -->
- [1. The fetch is a fact, not a formality](#1-the-fetch-is-a-fact-not-a-formality)
- [2. Evidence sources](#2-evidence-sources)
- [2b. A filter is not a ranking](#2b-a-filter-is-not-a-ranking)
- [3. The convention is learned from the remote](#3-the-convention-is-learned-from-the-remote)
- [4. The picker](#4-the-picker)
- [5. Autopilot](#5-autopilot)
- [6. State](#6-state)
<!-- /toc -->

**Pattern**: Phase 0 Step 3 used to ask one question with a list it could not
vouch for. `git fetch origin` ran, its exit code was ignored, and `git branch -r`
printed the remote-tracking cache either way - so on a restricted network a
weeks-old local list was presented as the remote's answer, with nothing in the
output saying so. And the answer was usually derivable: an issue that carries a
target version, or that links a separate issue representing the release, already
names the branch on a repo whose release branches encode the version. Nothing
derived it.

The shape here is **evidence collection, then a picker** - deliberately not a
rule engine. Every candidate carries why it is a candidate; the ranking orders
them; a human chooses. A rule engine would have to be right, and the inputs
(field names, branch spellings, board conventions) differ per repo and change
under it. An evidence list only has to be honest.

Implemented by `$HOME/.claude/scripts/base-branch-candidates.mjs` (pure, no git,
no network - it is handed ref lists and issue evidence). Gated by `prefs.global.baseBranchEvidence.enabled`
(default `true`; off falls back to the rule-6 sort order, still through the picker). Asserted by `smoke-base-branch-evidence.sh`
and `test/base-branch-candidates.test.mjs`.

## 1. The fetch is a fact, not a formality

```bash
git -C "$PROJECT_ROOT" fetch origin; FETCH_RC=$?
```

`FETCH_RC` decides `refProvenance`, which is a required input to the collector
and rides on every candidate as its own evidence row:

| `FETCH_RC` | `refProvenance` | Ref list | What the user is told |
|---|---|---|---|
| 0 | `remote` | `git branch -r` | nothing extra; the list is current |
| non-zero | `local` | `git branch -r` (cache) **and** `git branch` | the picker question itself says the fetch failed and these refs may be stale |

A degraded list is still a list - falling back is correct, hiding it is not. The
degraded picker gains a **Retry the fetch** row, and the fetch-fail picker
already in Step 3 (Connect VPN / cached / local branch / abort) still owns the
`baseFetchStatus` value. The two fit together: that picker records *what the run
is working from*, this one records *what the candidate list is worth*.

Local-only refs never silently become remote ones. `state.baseBranchEvidence.refProvenance`
must be `local` whenever `baseFetchStatus` is `cached-stale` or `local-branch`,
and `phase0-exit-gate.mjs` refuses to close Phase 0 otherwise. That assertion is
the whole of problem 1: the run may degrade, it may not misreport.

## 2. Evidence sources

| Kind | Where it comes from | Weight |
|---|---|---|
| `issue-version` | a version-typed field on the issue whose value matches a branch on the ref list | 100 exact, 60 same major.minor, x0.6 for an affects-version field |
| `linked-release` | a linked issue or parent whose own fix-version or summary names a version that matches a branch | 90 exact (110 when `baseBranchEvidence.preferLinkedRelease`) |
| `version-convention` | the release-branch template inferred from the ref list | annotation only, no points |
| `recent` | `prefs.global.recentBranches[{projectKey}]`, inside `settings.branchTtlDays` | 40 + up to 10 for recency |
| `repo-default` | `origin/HEAD` | 20 |
| `sort-order` | the develop / release / main families Step 3 always sorted by | 15 / 10 / 5 |
| `ref-provenance` | the fetch outcome above | annotation only, on every candidate |

### Field discovery, not a field table

No field id is hardcoded, and none can be: a board's "target version" is a
custom field whose id differs per Jira instance. What is stable is the *schema*.
`GET /rest/api/2/field` returns `[{id, name, custom, schema: {type, items, custom}}]`,
and any field whose schema resolves to `version` - directly, or as `array` with
`items: "version"` - is read, whatever it is called. The two system fields
(`fixVersions`, `versions`) are read unconditionally; an affects-version field
is weighted lower than a fix-version one because it describes where the bug was
seen, not where the fix lands.

`GET /rest/api/2/issue/<key>?expand=names` returns the same display names inline
and saves the second call when only labels are needed.

### The linked release issue

`fields.issuelinks[]` carries `{type: {inward, outward}, inwardIssue, outwardIssue}`
and `fields.parent` carries the parent of a sub-task. On a board where opening a
development sub-task requires selecting the related release issue, the link is
the authoritative answer and the version field is the corroboration - so
`prefs.global.baseBranchEvidence.preferLinkedRelease` (default `false`) raises the
linked-release weight above the version-field one rather than adding a second rule. A linked issue counts as
a release issue when a version can be read out of its own fix-version field or
its summary; the issue *type name* is not consulted, because type names are
per-board copy.

GitHub's analogue is the milestone title, read the same way.

## 2b. A filter is not a ranking

Step 3's rule-5 list is NOT narrowed with `grep -E '(develop|release|main|master)'`.
That is the prefix table this whole feature exists not to have - and worse than a
table, because it DISCARDS. A repo whose release branches read `stabilise-2.7`
matched none of the four words, so none of its branches reached the picker, the
convention-learner had nothing to learn from, and the version on the issue could
never match anything. The feature would have been inert on exactly the repos it
was written for.

The rule is the distinction: **a word list that ranks is fine, a word list that
filters is not.** Ranking only reorders rows the user can still see past; filtering
removes answers with nothing saying so. So the four family words stay where they
belong - rule 6's sort and this collector's 15/10/5 `sort-order` weights - and the
filter gained a version alternative that admits any branch carrying a version
token, whatever it is called.

The excluded prefixes (`feature/`, `bugfix/`, `fix/`, `hotfix/`, `chore/`) are a
different thing again: those are the task branches **this pipeline creates itself**,
in Step 4. Excluding your own output is not a convention assumption.

## 3. The convention is learned from the remote

A table of branch prefixes would be wrong for most repos the day it was written.
So the template is inferred from the refs that exist: every branch carrying a
version token is reduced to its shape by replacing the token with `<version>`,
and the most common shape is this repo's convention. A repo whose release
branches read `<prefix>/develop_<version>` produces that template; a repo that
spells them `release-<version>` produces that one; a repo with no versioned
branches produces `null` and the whole source goes quiet.

The inference is reported with its member count (`learned from 4 branch(es)`),
because an inference from one branch and an inference from a dozen are different
claims and the picker row should not flatten them.

**A predicted branch is never offered.** When the learned template predicts a
name that is not on the ref list, that is a note, not an option:

```
version 1.51.0 (field "Target Version") has no branch on the remote;
the convention <prefix>/develop_<version> learned from 4 branch(es) would spell it
<prefix>/develop_1.51.0
```

That sentence is more useful than a candidate would be - it tells the user the
release branch has not been cut yet, which is a real answer to "which base?" -
and an option the user picks has to be checkoutable. Offering a name that does
not exist just moves the failure to Step 8, where it reads as a git error.

## 4. The picker

`toPickerOptions()` builds the rows, and the **evidence is the description**. A
row reading `matches version 1.51.0 from the issue field "Target Version"` and a
row reading `the repository's default branch` are different answers to the same
question; a picker that hides which one it is cannot be chosen on its merits.

The two-option floor is enforced inside that function rather than left to the
caller: it never returns fewer than two rows, because `AskUserQuestion` refuses a
question with fewer than two declared options *and discards every question
batched with it*, and the host's injected Other row does not count toward the
schema minimum. The escape row (`Show all branches`) is a genuine second choice,
not an `OK` button - see picker-contract.md, "Two options or it is not a
question".

**Interactive runs always ask.** A derived candidate is a better-ordered list,
never a skipped question. `baseBranchSource` stays `asked`, because a human
answered; the derivation is recorded separately in `state.baseBranchEvidence` so
it stays readable afterwards.

## 5. Autopilot

Autopilot cannot be asked anything, so it resolves in this order and records
which rule fired in `baseBranchSource`:

1. `remembered` - `recentBranches` inside the TTL and still on the ref list (memory outranks the default, per the picker contract).
2. `derived` - the top candidate carries `issue-version` or `linked-release` evidence **and** no other candidate ties its score. Ambiguity is not resolved by coin-flip.
3. `default` - the sort order.

`derived` is an autopilot-only value, exactly like `remembered` and `default`;
an interactive run recording it has skipped its picker, and the exit gate fails
it. The gate additionally refuses `derived` unless `state.baseBranchEvidence`
actually contains issue-derived evidence for the chosen branch - a `derived` that
derived from nothing is `default` wearing a hat.

### Asking on the issue (`prefs.global.baseBranchEvidence.autopilotAsksOnIssue`, default OFF)

When the derivation is **ambiguous** and nothing is remembered, autopilot can
post one comment on the Jira issue or GitHub issue asking which branch to
develop from, then halt.

This is an outward-facing write, so it is fenced:

- **Off by default.** Nothing posts unless the user turned it on for this project.
- **A question, never a state change.** No transition, no resolution, no assignee change, no label change, no close - ever. The standing rule that the pipeline never auto-closes an issue is not relaxed by this feature, and a comment is the only write it is allowed to make.
- **Human-facing copy follows `outputLanguage`**, like every other comment the pipeline writes.
- **`Ref:`, never `Closes:`/`Fixes:`/`Resolves:`** in the body, so no platform-side automation reads it as an instruction.
- **It renders the same evidence the picker would have shown**, candidate by candidate, so the person answering sees what the run saw.
- **Then the run halts.** Posting a question and continuing on a guess is worse than not asking: the guess lands in a branch while the question sits unanswered. So the comment trips the circuit breaker (trigger 6), which is the sanctioned autopilot pause - state recorded, one actionable line printed, waiting for an explicit `resume`. Phase 0 does not close and no worktree is created.

The nearest-safe reading of "post a comment asking which branch" is therefore:
ask once, change nothing, stop. An autopilot that posts a question and then
answers it itself has not asked anything.

## 6. State

```jsonc
"baseBranchSource": "asked" | "input" | "remembered" | "default" | "derived",
"baseBranchEvidence": {
  "refProvenance": "remote" | "local",   // required whenever this object exists
  "chosen": "<branch>",
  "ambiguous": false,
  "convention": { "template": "<prefix>/develop_<version>", "members": 4 },
  "candidates": [
    { "branch": "<branch>", "score": 115,
      "evidence": [{ "kind": "issue-version", "detail": "..." }] }
  ],
  "notes": ["..."],
  "askedOnIssue": { "target": "PROJ-1234", "url": "...", "at": "..." }
}
```

`baseBranchEvidence` is required when `baseBranchSource` is `derived`, and when
`baseFetchStatus` is `cached-stale` or `local-branch`. Everywhere else it is
optional: a run that took the base from the task reference has no evidence to
record and should not be made to invent some.
