# Picker Contract (cross-platform single-choice abstraction)

> Native `AskUserQuestion` is a Claude-Code primitive; Copilot CLI has no
> agent-invokable choice picker. This contract defines ONE abstract "ask the
> user to choose" primitive that each supported CLI renders to the best
> mechanism it offers.

## The abstract primitive

`ask_choice(question, options[], { header?, default?, allowFreeText? })`

- `question` - rendered in `outputLanguage`.
- `options[]` - each `{ label (outputLanguage), description (outputLanguage) }`. A label naming a branch, an account or another proper noun is passed through verbatim rather than translated.
- `default` - recommended option, given as a **1-based index**; used by autopilot / non-interactive runs. Labels are localized, so a label-valued default resolves differently on `en` and `tr`.
- `allowFreeText` - when true, a free-text answer is accepted as an extra channel (e.g. the plan-gate's edit request).

Returns the selected option label (or the free-text string when `allowFreeText` and the user typed instead of choosing).

## Step narration (breadcrumb)

A picker chain (account → repo → maturity → dev-context, or the analysis Phase 0 chain) must tell the user where they are. Before each `ask_choice` / `AskUserQuestion` in a multi-step chain, emit ONE narrator line:

`<localized: "Step <i>/<n>: <what this step decides>">`

- `<i>` is the 1-based position, `<n>` the total steps the active flow will run (known up front: e.g. an analysis run with one platform is 5 steps; a Jira-ID input is 3). When the count is genuinely unknown, omit `/<n>` and print `Step <i>: ...`.
- The line renders in `outputLanguage` (it is conversational copy, like `question`). Turkish: `Adim 2/5: hesap secimi`. English: `Step 2/5: account selection`.
- Auto-selected / skipped steps (single account, local-only flow) still print their breadcrumb with the resolution noted (`Step 1/5: account selection (auto: <label>)`), so the chain reads continuously instead of jumping numbers.
- It is a narrator line above the picker, never part of the `question`, `label`, or `header`.

This is the surface that makes the native picker show, step by step, what it is doing. Every picker file (`_account-picker.md`, `_repo-picker.md`, `_dev-context.md`) and the analysis Phase 0 chain reference this contract.

## Per-platform rendering (degradation ladder)

| Platform | Render |
|---|---|
| **Claude Code** | native `AskUserQuestion` (question/label/description in `outputLanguage`, `header` English); free-text via the built-in **Other** field, which the host injects in English and no run can localize |
| **Copilot CLI** | `pipeline/lib/ask-choice.sh` (numbered menu); a future MCP elicitation tool can replace this once host support is broad |

## Universal fallback: `pipeline/lib/ask-choice.sh`

A zero-dependency numbered-menu picker for any shell-capable runtime:

```bash
choice=$(pipeline/lib/ask-choice.sh "Do you approve this plan?" "Approve" "Cancel" "Edit")
```

- Prints the menu + prompt on **stderr**; echoes ONLY the chosen label on **stdout**.
- `ASK_CHOICE_DEFAULT=<1-based-index|label>` selects without prompting (autopilot / CI). The script still resolves either, but pipeline callers **must** pass the index: a label is localized copy and stops matching the moment `outputLanguage` changes.
- No TTY + no default -> picks the first option and never blocks an automated run.

Enforced by `smoke-ask-choice.sh`.

## Localized labels: what the caller owns

`label` is copy the user reads, so it renders in `outputLanguage` like `question`
and `description`. Three consequences belong to whoever calls the picker:

- **Branch on the choice, not the string.** The returned label is localized, so a
  literal comparison against an English word resolves differently on `en` and `tr`.
  Match on which option was selected and map it back to its canonical English value
  before it reaches `agent-state.json`, a commit message, a branch name or a log line.
- **Defaults are indices.** Pass `default` and `ASK_CHOICE_DEFAULT` as a 1-based index.
  Both still accept a label, which is exactly the trap: a label-valued default keeps
  working until someone switches `outputLanguage`, then silently matches nothing and
  `ask-choice.sh` falls through to the prompt (or, with no TTY, to option 1).
- **Proper nouns pass through.** A label naming a branch, an account, a repo or a
  stack id is rendered verbatim, never translated.

**Reading a spec file.** Every `label: "..."` written in a phase doc, a ref or a
command spec is the option's semantics, recorded in English because instruction prose
is English. It is not the string to print. Render it in `outputLanguage` at call time.
The same goes for prose that names an option (`If the user picks "Cancel"`): it
identifies which option, not what the button said.

The host injects its own **Other** free-text row in English on every run. Nothing in
the pipeline can localize it, and no option may depend on its wording.

## Order: project, then repo, then branch

A picker may only be asked once everything it depends on is settled, and the
dependency runs one way: a base branch is a property of a repo, and a repo is a
property of a project. Asking for a branch before the dev-context repo set is
known means the answer was given about a repo the run had not chosen yet, and
nothing downstream can tell that apart from a correct answer.

So: project selection, then `_dev-context.md`, then the base branch. A step whose
input is not yet resolved waits; it does not guess and it does not resolve its own
input with a second question.

## A single candidate is still a question

The number of options never authorises a skip. A filter that leaves one row has
narrowed the world; it has not decided anything, and the host's **Other** row is a real
choice on every picker - a branch the filter excluded, an account the probe missed, a
repo git does not know about. "There was only one option, so I picked it" is a skipped
picker, and announcing the pick in prose first is the same skip with a sentence in front
of it.

This is the failure that is hardest to see afterwards, because the transcript reads like
a decision was made. Only the picker's absence records that the user was never asked.

## Autopilot / non-interactive contract

In autopilot, `ask_choice` resolves to `default` (or the safe first option) without prompting - identical to how the native gates auto-proceed today. A picker is only surfaced for genuinely ambiguous or destructive decisions, matching the maturity-check model.

**Memory outranks the default.** Where the pipeline has recorded what this user chose
last time for this project - `prefs.global.recentBranches[{projectKey}]` is the one that
exists today - autopilot resolves from that record first, and only falls back to the
option order when the record is empty, stale past its TTL, or names something that no
longer exists. A remembered choice is evidence about this user and this repo; an option
order is a guess that happens to be sorted. Where the two agree nothing changes, and
where they disagree the remembered one is the answer with a reason behind it.

The run records which rule fired (`remembered` or `default`). An autopilot run cannot be
asked anything, so the only thing that keeps it accountable is being readable afterwards.

## Deterministic gates note

Claude Code's `PreToolUse` exit-2 hooks are the HARD blocking gates. Three ship, none needing run-specific arguments so they are naturally hookable: (1) `pre-commit-check.sh` scans the staged diff on every `git commit` and blocks on a detected secret; (2) `agent-guard.sh` runs on `git commit` + `git push` and blocks AI/assistant attribution in a commit message and force-push to a protected branch (main/master/develop); (3) `check-read-size.sh` runs on `Read` and on the shell commands that read a file whole, and routes an oversized read to a cheap worker (`bulk-read.sh`) instead of the caller's own rung. The first two inspect what a run WRITES; the third inspects what it pays to READ, and it is inert until `bulkRead.mode` is set to `observe` or `enforce`, so merging the block changes nothing until the user opts in. Its `observe` mode blocks nothing and only logs, which is how the baseline is measured before anything is routed. All three are self-contained, fail-open on internal error, and never execute the inspected command. Two capture hooks ship in the same block and block nothing: `SessionEnd` runs `capture-flush.sh --if-stale` (writing a killed run's findings into the per-repo stores, since every durable write used to live in Phase 7 - the phase a run is least likely to reach) plus `note-session.sh` (the mechanical shape of a non-pipeline session: tools used, commands that failed, calls the user refused - never an argument, never any output), and `SessionStart` runs `capture-resume.sh`, at most two lines about an unfinished run and a stale observation queue. Neither calls a model; both exit 0 on every path. The recommended hook block ships at `install/templates/claude-hooks.json`; `multi-agent:setup` offers to merge it into `~/.claude/settings.json`. The other deterministic gates (evidence, consensus, intent, learnings) are invoked by the pipeline phases with per-run arguments (a build-log path, the triage JSON, the free-text input), so they are phase-enforced by contract, not OS-hookable.

Copilot CLI has no `PreToolUse` equivalent, so the secret scan there is workflow-enforced (run as a phase step, not OS-blocked) plus a CI smoke-gate step.
