---
name: code-review-pre-release
description: >
  Pre-release review: analyse all changes between the last release tag and the default branch
  using the shared review criteria (bugs, security, performance, breaking changes), and verify
  the pending changelog entries are coherent with the commit messages and the changes actually
  observed in the diff (missing, stale, mistyped, or misleading entries).
  For each issue, ask the user to create a GitLab ticket (via /glab-issue-create) or ignore;
  for each changelog incoherence, optionally add/reword/remove the entry on the release branch.
  Propose to proceed with the release (/ci-release) or stop to fix the created tickets,
  then post the recap (verdict + ticket links + action taken) on the repo's tech Slack channel.
  Use when the user says /code-review-pre-release, /release-review, 'review avant release',
  'check avant release', 'qu est-ce qui va sortir', 'audit pre-release', 'vérifie les changelogs',
  or wants to vet the upcoming release before triggering /ci-release.
argument-hint: "[optional: app name] [--support vX.Y.x|vX.x.x (or run from a support/* branch) to vet a support-line release instead of the default branch] [--local to skip GitLab ticket creation and Slack post]"
allowed-tools:
  - Read
  - Glob
  - Grep
  - Bash
  - Edit
  - Skill
  - AskUserQuestion
hooks:
  # Edit is permitted ONLY on files under changelogs/ — the single mutation this
  # skill performs (rewording a pending changelog entry in Step 9 on the release
  # branch). Every other path stays blocked: source files are reviewed, not fixed.
  - type: PreToolUse
    matcher: Edit
    hook: |
      FP=$(jq -r '.tool_input.file_path // ""' 2>/dev/null)
      case "$FP" in
        */changelogs/*) exit 0 ;;
        *) echo "BLOCKED: code-review-pre-release only edits files under changelogs/. Create tickets via /glab-issue-create or fix code via /code-review-fix." >&2; exit 2 ;;
      esac
  # Write stays fully blocked: new changelog entries are created via the sanctioned
  # 'pysae-ai-tools code changelog generate', never hand-written.
  - type: PreToolUse
    matcher: Write
    hook: |
      echo "BLOCKED: code-review-pre-release does not create files directly — add changelog entries via 'pysae-ai-tools code changelog generate'. Create tickets via /glab-issue-create or fix code via /code-review-fix." >&2
      exit 2
---

Review every change merged into the default branch since the last release tag to vet the **production deployment** — surface only what carries real prod risk (regressions, breaking changes, unintended changes, merge/rebase integration problems), **not** code-quality issues (each MR was already validated). See [Step 4](#step-4--apply-review-criteria). In the same pass, **verify the pending changelog entries are coherent** with the commit messages and the changes actually observed in the diff — these entries are exactly what `/code-release-notes` and the `CHANGELOG.md` merge will ship to users, so a missing, stale, mistyped, or misleading entry is a real defect. See [Step 4b](#step-4b--changelog-coherence-check). Then either create follow-up tickets, correct the changelogs, and/or proceed with `/ci-release`.

## Required tools

- `glab`
- `jq`

Run `pysae-ai-tools tools require <tools>` before starting; if it exits non-zero, abort and report the install command from stderr to the user.

## Project configuration

Honour `.pysae-ai-tools.yaml` (this repo's `CLAUDE.md` is NOT in your context at runtime). Check flags with `pysae-ai-tools project flag <path>` (exit 0 = on, 1 = off — no `jq`):

- `release.review.enabled` — when `pysae-ai-tools project flag release.review.enabled` exits non-zero, **skip the pre-release review entirely** (stop cleanly, no findings, no tickets).
- `changelog.enabled` — when `pysae-ai-tools project flag changelog.enabled` exits non-zero, **skip the changelog coherence check (Step 4b) and any correction (Step 9)** — this repo does not use changelogs. (A missing `CHANGELOG.md` at the repo root is the same signal; in that case there are no pending entries to verify either.)
- `slack.notifications.prerelease_review` / `slack.enabled` — when off, **skip the Slack recap post** (still run the review).
- `release.review.prompt` — non-boolean: read with `pysae-ai-tools project show --json release.review.prompt` only if you need its text (the value may inline the guidance, be an `overlay:` reference to a file/URL, or come from the `.pysae-ai-tools/` overlay directory — `project show` returns it resolved); when set, inject it as extra guidance for the review.

## Step 1 — Resolve context

If `$ARGUMENTS` contains `--local`, set `LOCAL_ONLY=true` and strip it before further parsing. Local-only mode skips **all** external side effects: no GitLab ticket creation, **no Slack post**. Issues are only printed in the terminal.

```bash
CTX=$(pysae-ai-tools internal detect-context)
IS_CI=$(echo "$CTX" | jq -r '.is_ci')
PROJECT_ID=$(echo "$CTX" | jq -r '.project_id // empty')
PROJECT_PATH=$(echo "$CTX" | jq -r '.project_path // empty')
PROJECT_URL=$(echo "$CTX" | jq -r '.project_url // empty')
# Stable routing (Slack channel) lives behind `project show`, not detect-context.
# Default = current repo; when targeting a different app, re-resolve with --project (below).
{ IFS= read -r TECH_SLACK_CHANNEL; IFS= read -r TECH_SLACK_CHANNEL_ID; } < <(
  pysae-ai-tools project show slack.tech_channel slack.tech_channel_id)

# Canonical short app name — ALWAYS the project path WITHOUT the `pysae/` group
# prefix (e.g. pysae/api → api, pysae/infra/infra-cluster → infra/infra-cluster).
# Use ${app} verbatim everywhere it appears below (report header, Slack post,
# find-thread lookup, /ci-release args). Consistency is mandatory: the Slack
# header is the lookup key used to find/cancel a previous thread (Step 7), so a
# drift between "api" and "pysae/api" would orphan the old thread.
app=$(printf '%s' "${PROJECT_PATH#pysae/}")
```

CI mode (`IS_CI == "true"`): never use `AskUserQuestion`. Print the full issue report, **post the Slack recap (Step 7) anyway** — the team needs to know the release was vetted — and exit. Do not create tickets, do not trigger the release.

**Target application** — **default**: `pysae/api` (or the current repo). If targeting a different repo than the current directory, resolve `PROJECT_ID` via `glab api "projects/<url-encoded-path>"`, update `PROJECT_URL`, **re-derive `app` by stripping the `pysae/` prefix** so the Slack header stays consistent, and **re-resolve the Slack channel for the target** so the recap lands on the right channel (list repos with `pysae-ai-tools project list`):
```bash
{ IFS= read -r TECH_SLACK_CHANNEL; IFS= read -r TECH_SLACK_CHANNEL_ID; } < <(
  pysae-ai-tools project show --project "$PROJECT_PATH" slack.tech_channel slack.tech_channel_id)
```

## Step 2 — Resolve the last tag, base branch, and target version

**Support release detection** — a pre-release review can vet a **support line** (`support/vX[.Y].x`) instead of the main line. Detect it from `$ARGUMENTS` (`--support v5.2.x` or a `support/...` ref) or the current branch, then set `BASE_BRANCH` — the branch being released and reviewed against — to the support branch; otherwise it is the default branch:

```bash
SUPPORT_MODE=false; SUPPORT_BRANCH=""; SUPPORT_LINE=""
SUPPORT_LINE=$(printf '%s\n' "$ARGUMENTS" | sed -n 's@.*--support[ =]\(v\?[0-9][0-9.xX]*\).*@\1@p')
SUPPORT_BRANCH=$(printf '%s\n' "$ARGUMENTS" | sed -n 's@.*\(support/v\?[0-9][0-9.xX]*\).*@\1@p')
if [ -z "$SUPPORT_BRANCH" ]; then CUR=$(git branch --show-current 2>/dev/null || true); case "$CUR" in support/*) SUPPORT_BRANCH="$CUR" ;; esac; fi
if [ -n "$SUPPORT_BRANCH" ] || [ -n "$SUPPORT_LINE" ]; then
  SUPPORT_MODE=true
  [ -z "$SUPPORT_LINE" ] && SUPPORT_LINE="${SUPPORT_BRANCH#support/}"
  [ -z "$SUPPORT_BRANCH" ] && SUPPORT_BRANCH="support/${SUPPORT_LINE}"
fi
```

1. **Default branch** — read it from GitLab (never assume `main`), then resolve `BASE_BRANCH`:
   ```bash
   DEFAULT_BRANCH=$(glab api "projects/${PROJECT_ID}" | jq -r '.default_branch')
   BASE_BRANCH="${DEFAULT_BRANCH}"
   $SUPPORT_MODE && BASE_BRANCH="${SUPPORT_BRANCH}"
   ```
   From here on, every diff/changelog/release-branch operation reviews against `BASE_BRANCH`.

2. **Fetch tags and the base branch** to make sure the diff is accurate:
   ```bash
   git fetch --tags --prune origin ${BASE_BRANCH}
   ```
   If `git fetch` fails (not the right local repo), abort with a clear error message asking the user to run the skill from inside the target repo's working tree.

3. **Latest release tag** — pick the highest semver tag from git, sorted with `version:refname` so `v1.10.0` ranks above `v1.9.0`. In support mode, **restrict to the support range** so the diff starts from the support line's last tag, not a later major on main:
   ```bash
   TAG_GREP='^v?[0-9]+\.[0-9]+\.[0-9]+$'
   $SUPPORT_MODE && TAG_GREP="^v?$(printf '%s' "${SUPPORT_LINE#v}" | sed -E 's/\.?[xX]//g' | sed 's/\./\\./g')\\."
   LATEST_TAG=$(git tag --list --sort=-version:refname | grep -E "$TAG_GREP" | grep -E '^v?[0-9]+\.[0-9]+\.[0-9]+$' | head -1)
   ```
   If `LATEST_TAG` is empty, no previous release exists on this line — fall back to the base branch's first commit (`git rev-list --max-parents=0 origin/${BASE_BRANCH} | tail -1`) and warn the user that the review will cover the entire history.

4. **Next version** — delegate to `/code-get-next-version <app>` to compute and (in local mode) confirm with the user the version that would be released right now. This lets the rest of the skill show concrete `vX.X.X → vY.Y.Y` framing in the report header, the Slack post, and the final triage line. In support mode, forward `--support-line` + `--ref` so the version is clamped to the range and the bump is read from the support line:

   ```
   /code-get-next-version <app>                                            # normal
   /code-get-next-version <app> --support-line ${SUPPORT_LINE} --ref ${SUPPORT_BRANCH}   # support
   ```

   The delegated skill ends its response with a `NEXT_VERSION_RESULT:` JSON block followed by a handoff marker (contract: see [../code-get-next-version/SKILL.md § Step 5](../code-get-next-version/SKILL.md)). Read that block from the delegated skill's response and extract `new_version` + `cancelled` — set `NEW_VERSION` and `NEXT_VERSION_CANCELLED` from those fields. If you want a shell-extracted form, capture the delegated skill's stdout to a `RESULT` variable first:

   ```bash
   NEW_VERSION=$(echo "$RESULT" | sed -n '/^NEXT_VERSION_RESULT:/,/^}/p' \
     | sed '1d' | jq -r '.new_version')
   NEXT_VERSION_CANCELLED=$(echo "$RESULT" | sed -n '/^NEXT_VERSION_RESULT:/,/^}/p' \
     | sed '1d' | jq -r '.cancelled')
   ```

   **Do NOT stop after parsing the JSON block.** The `NEXT_VERSION_RESULT:` block is *not* a terminal output of this skill — it's an intermediate value returned by the delegation. Reading it means « j'ai la version, je continue » : immediately move on to Step 3 (collecting the diff). The only stop signal is `NEXT_VERSION_CANCELLED == "true"` — in that case the user cancelled before the review started, abort here. In every other case the user has arrested the version they want released, carry `NEW_VERSION` forward through Steps 3–10 without pausing for confirmation.

## Step 3 — Collect the diff

```bash
DIFF_RANGE="${LATEST_TAG}..origin/${BASE_BRANCH}"
git log --oneline ${DIFF_RANGE}            # commit list for context
git diff --stat ${DIFF_RANGE}              # files touched + line counts
git diff ${DIFF_RANGE}                     # full diff to analyse
```

- **Read modified files** for full context (imports, types, surrounding logic). Stay scoped to diff lines only.
- **Diff convention**: lines with `-` prefix are **already deleted** — never suggest fixing them (only flag if deletion causes a regression). Lines with `+` are new code to review.
- **Large diffs** (>1000 lines): split the analysis by file or by commit theme to avoid quality degradation. Prioritise files in `src/`, `app/`, `api/`, `pkg/` over generated code, lockfiles, and snapshots.
- **Skip noise** — lockfiles (`package-lock.json`, `poetry.lock`, `pnpm-lock.yaml`), `dist/`, `build/`, fixtures, snapshots, vendored code. Mention them at most once in the final report under "Hors périmètre".

If a `CLAUDE.md` file exists at the repo root, apply its conventions too.

## Step 4 — Apply review criteria

**This review vets a production deployment — not code quality.** Every MR in this diff was already reviewed and validated manually before merge. So the question for each candidate finding is **"will this break in production once deployed?"**, never "is this code clean?". Report ONLY things that carry real production risk:

- **regressions** — behaviour that worked before the release and no longer does;
- **breaking / backward-incompatible changes** on public contracts (API, CLI, schema, config, events) — once tagged, the diff ships to every consumer;
- **unintended or accidental changes** — a change that doesn't match its MR's intent, debug code left in, a flag/default flipped by mistake;
- **integration problems from the successive merges/rebases** of the MRs since the release — a conflict mis-resolved, a change from one MR silently undone or duplicated by another, branch drift. Cross-read the commits for this kind of artifact: the diff is the *combined* result of many MRs that never ran together until now.

**Do NOT report code-quality issues with no real production impact** — style, naming, structure, minor refactors, missing comments, test-coverage nits. They are noise at this stage and must be dropped, not downgraded to `Minor`.

Severity (`Critical` / `Major` / `Minor`, per [../references/review-criteria.md](../references/review-criteria.md)) still applies, but only to findings that pass the production-risk bar above. An empty report (`No issues found.`) is the expected, good outcome when the integration is clean.

## Step 4b — Changelog coherence check

The **pending changelog entries** — the `changelogs/*.md` files present on the default branch *right now* — are exactly the lines `/code-release-notes` synthesises from and that `pysae-ai-tools code changelog release` will merge into `CHANGELOG.md` (then delete) when this version ships. A file still sitting under `changelogs/` = **not yet released**. So this is the moment to confirm they tell the truth about what is actually shipping.

**Skip this step entirely** when the repo does not use changelogs — either `pysae-ai-tools project flag changelog.enabled` exits non-zero, or there is no `CHANGELOG.md` at the repo root (then there are no pending entries). Note it once under "Hors périmètre" and move on.

### Collect the pending entries

Read them from the default branch tip (not the local working tree, which may be on another branch), and note which were added since the tag:

```bash
# Each pending entry, as it stands on the branch being released.
git ls-tree -r --name-only "origin/${BASE_BRANCH}" -- changelogs/ | grep -E '\.md$'
# Body of one entry:  git show "origin/${BASE_BRANCH}:changelogs/<file>.md"
# Which entries are new since the last release (added/modified/deleted):
git diff --name-status "${LATEST_TAG}..origin/${BASE_BRANCH}" -- changelogs/
```

Parse each entry into `type`, `description`, `iid` from the `* <type>: <description> (#<iid>)` skeleton, and keep the source-branch slug from the filename (`<type>-<iid>-<slug>.md`).

### Cross-reference against the two truth sources

For **each pending entry**, and for **each user-facing change in the diff**, reconcile the entry against **both**:

1. **The commit messages** (`git log ${DIFF_RANGE}` from Step 3) — conventional-commit type, scope, referenced issue/MR IIDs, and the merge commits' source branches.
2. **The changes actually observed** while reviewing the diff (Steps 3–4) — what the code really does now, regardless of what any message claims.

Classify every mismatch into one of these **coherence findings** (this is a distinct axis from the production-risk findings of Step 4 — keep the two sets separate):

- **`missing`** — a user-facing change landed in the range (a `feat`/`fix`/`perf` MR a user would notice) but **no** changelog entry describes it. Proposed correction: **add** an entry.
- **`orphan`** — a pending entry describes a change that is **not present** in the diff (the work was reverted, never landed, or already shipped in a prior tag). Proposed correction: **remove** the entry.
- **`wrong-type`** — the entry's `type` contradicts the commits and the observed change (e.g. entry says `feat` but the diff is a bug fix, or `tech` for a user-visible feature). Proposed correction: **retype**.
- **`inaccurate`** — the entry's wording does not match what the code actually does (wrong scope, describes the old behaviour, claims something that was dropped during merge/rebase, or is so vague it would mislead the release notes). Proposed correction: **reword**.
- **`wrong-iid`** — the `(#iid)` points at the wrong ticket or is missing/malformed. Proposed correction: **fix the ref**.
- **`duplicate`** — two entries describe the same change (often a merge/rebase artifact). Proposed correction: **remove** the redundant one.

Mapping an entry to its commits is best-effort: match on the `(#iid)`, on the filename's source-branch slug appearing in a merge commit, and — decisively — on whether the **observed diff** contains the change the entry claims. When the commit message and the observed change disagree, **trust the observed change**: the diff is the combined, post-merge reality; a commit message can be stale or describe an intent that a later MR undid.

**Calibrate to user impact, not completeness.** Not every commit needs an entry — `tech`/`refactor`/`chore`/`test`/`ci`/`docs` changes with no user-visible effect legitimately have none, and that absence is **not** a `missing` finding. Flag a `missing` entry only for a change a user (or API/CLI consumer) would actually notice. Likewise, do not nitpick wording that is merely terse — only flag `inaccurate` when the entry would genuinely mislead someone reading the release notes.

Each coherence finding carries: the kind, the affected entry (or the change with no entry), the source commit(s), and a concrete **proposed correction** (the exact replacement bullet for add/reword/retype, or the file to remove). These feed the report (Step 5), the optional triage (Step 6b), and — when the user proceeds with the release — the actual correction on the release branch (Step 9, sub-step 9a-bis). A clean result (`Changelogs cohérents`) is the expected, good outcome.

## Step 5 — Format and print the report

Follow [../references/review-issue-format.md](../references/review-issue-format.md) for severity labels, numbering (`\#<N>`, sorted Critical > Major > Minor), file permalinks, and the per-issue block structure.

`/code-review-pre-release`-specific additions on top of the shared format:

- **`COMMIT_SHA`** = `$(git rev-parse origin/${BASE_BRANCH})` (head of the branch being released — the default branch, or the support branch in support mode — not the local working tree).
- **Always include the `**Commit**` field** in every issue block — cite the commit that introduced the problem so it can be reverted in one move if needed.
- **Header** — print once before the issue list, using the version pair resolved at Step 2:
  ```
  ## Review pre-release `<LATEST_TAG>` → `<NEW_VERSION>` — `<app>`

  - Range : `<LATEST_TAG>..origin/<DEFAULT_BRANCH>`
  - Commits inclus : <N>
  - Fichiers touchés : <N>
  - Lignes : +<add> / -<del>
  ```
- **Changelog coherence section** — after the production-risk issues (and before the verdict), print a `### Cohérence des changelogs` block summarising Step 4b. When everything lines up, a single line `:white_check_mark: Changelogs cohérents (<N> entrées en attente).`. Otherwise, one bullet per coherence finding, in this fixed kind order — `missing` → `orphan` → `wrong-type` → `inaccurate` → `wrong-iid` → `duplicate`:
  ```
  ### Cohérence des changelogs

  - **manquant** — `feat:` <change> (commit <sha>, MR !<iid>) sans entrée → ajouter `* feat: <english desc> (#<iid>)`
  - **périmé** — `changelogs/<file>.md` décrit <X>, absent du diff → supprimer
  - **mauvais type** — `changelogs/<file>.md` : `feat` mais c'est un correctif → `* fix: <desc> (#<iid>)`
  - **inexact** — `changelogs/<file>.md` : « <wording actuel> » ne reflète pas <ce que fait le code> → « <reformulation> »
  ```
  This block is **informational and does not change the production-risk verdict below** — a misworded changelog is a docs defect, not a prod risk. The exception: an entry so wrong it would actively mislead users (e.g. announces a feature that was reverted) may justify bumping the verdict to **MODIFICATIONS REQUIRED** at your discretion — say so explicitly if you do.
- **Empty review** → print `**"Aucun problème détecté."**` (production-risk section) — but **still print the `### Cohérence des changelogs` block**: a clean diff can still carry an incoherent changelog. Skip Step 6's prod-risk triage (no issues), but run Step 6b if there are coherence findings.
- **Final verdict** (mandatory last line): **APPROVED** / **MODIFICATIONS REQUIRED** / **BLOCKED** — see [../references/review-issue-format.md § Règles de présentation](../references/review-issue-format.md).

## Step 6 — Triage each issue (local mode only)

Skip entirely if `IS_CI == "true"` or `LOCAL_ONLY == "true"`.

**Release tagging — every ticket created here gets two extra labels** so the whole batch is traceable to this release review:

- `~"review::release"` — group label marking a pre-release-review finding (already provisioned on the `pysae` group).
- `~"version::<NEW_VERSION>"` — the target release version (the `NEW_VERSION` resolved at Step 2, always `v`-prefixed, e.g. `version::v1.4.0`).

The `version::*` label is per-release and may not exist yet — **ensure it once, before creating any ticket** (idempotent group-level create). **Guard first**: only tag with a version when `NEW_VERSION` is a real semver — otherwise a parsing glitch upstream would mint a junk `version::v` group label that is painful to clean up. If the guard fails, warn, set `VERSION_LABEL=""`, and tag tickets with `review::release` only:

```bash
if printf '%s' "$NEW_VERSION" | grep -Eq '^v?[0-9]+\.[0-9]+\.[0-9]+([-.+][0-9A-Za-z.-]+)?$'; then
  VERSION_LABEL="version::v${NEW_VERSION#v}"   # normalise to a single leading v
  glab api "groups/pysae/labels?per_page=100&search=${VERSION_LABEL}" \
    | jq -e --arg n "$VERSION_LABEL" '.[] | select(.name == $n)' >/dev/null 2>&1 \
    || glab api --method POST "groups/pysae/labels" \
         -f name="$VERSION_LABEL" -f color="#6699cc" \
         -f description="Tickets liés à la release ${VERSION_LABEL#version::}" >/dev/null
else
  echo "WARN: NEW_VERSION='${NEW_VERSION}' is not a valid semver — skipping the version:: label, tickets get review::release only." >&2
  VERSION_LABEL=""
fi
```

When building the `--extra-labels` set below: always include `review::release`; include `${VERSION_LABEL}` only when it is non-empty.

For each issue, ask the user what to do with `AskUserQuestion`. Group up to 4 issues per call (the `AskUserQuestion` limit) and iterate until every issue is triaged. For each question:

> **\#<N> — <SEVERITY>** — `<short title>` (`<file>`)

Options (single-select):
- **Créer un ticket** — delegate to `/glab-issue-create` with the issue content (title, severity, file link, commit ref, problem, suggested fix)
- **Ignorer** — leave it out, no ticket created
- **Voir détail** — print the full issue body again then re-ask the same question

When the user picks **Créer un ticket**:

**Always go through a real `/glab-issue-create` delegation** — actually invoke the skill, never hand-roll `glab issue create` inline. Delegating is what gives each ticket the full treatment: the right type template, parent-epic matching, and a **Weight** (`/glab-issue-create` guarantees a Fibonacci weight — see its Step 3).

1. Invoke `/glab-issue-create` with the issue context as `$ARGUMENTS`. Pre-fill:
   - Title (English, action-oriented, no severity prefix)
   - Body: French description, copy the `**Problème**` + `**Correctif suggéré**` sections, add `**Détecté par** : /code-review-pre-release sur le range \`<LATEST_TAG>..origin/<DEFAULT_BRANCH>\``
   - Suggest labels matching the severity (`priority::P1` for Critical, `priority::P2` for Major, `priority::P3` for Minor) — `/glab-issue-create` validates/overrides as usual
   - **Board column: always `workflow::Refinement`.** Pre-release findings still need scoping before they are worked on, so they enter the board in the Refinement column — never `workflow::Ready` or `workflow::To Do`. Tell `/glab-issue-create` to use `~"workflow::Refinement"` (its default, so just don't let it pick a more advanced column)
   - **Always append the release labels** to the set passed to `/glab-issue-create`: `review::release`, plus `${VERSION_LABEL}` when it is non-empty (see the guard above). Pass them explicitly in `$ARGUMENTS` (e.g. `--extra-labels "review::release,${VERSION_LABEL}"`, or `--extra-labels "review::release"` if the version guard failed) so they survive `/glab-issue-create`'s own label detection
2. Collect the resulting GitLab issue URL.

Keep a running map `<N> → ticket_url | "skipped"` so the final summary lists every triaged issue. **This map is the input to Step 7's Slack post** — do not move on until every issue has been triaged and every "Créer un ticket" choice has returned a real GitLab URL.

## Step 6b — Triage the changelog corrections (local mode only)

Skip entirely if `IS_CI == "true"` or `LOCAL_ONLY == "true"`, or if Step 4b produced no coherence findings.

For each coherence finding from Step 4b, ask the user what to do with `AskUserQuestion` (group up to 4 per call, iterate until all are triaged). For each:

> **<kind>** — `<entry or change>` → <proposed correction>

Options (single-select):
- **Appliquer la correction** *(Recommended for `missing` / `orphan` / `wrong-type` / `wrong-iid` — high-confidence mechanical fixes)* — record the correction to apply in Step 9a-bis.
- **Ignorer** — leave the entry as-is.
- **Voir détail** — print the source commit(s), the current entry, the observed change, and the exact proposed bullet, then re-ask.

Record every "Appliquer" choice into a `CHANGELOG_FIXES` list, each item carrying the **action** (`add` / `reword` / `retype` / `remove`), the target `changelogs/<file>.md` (for reword/retype/remove) or the resolved `--type` + `--issue-iid` + English description (for `add`), and the exact final bullet. **Do not apply anything here** — Step 6 and 6b are still part of the read-only review. The corrections are applied later, on the release branch, only when the user proceeds with the release (Step 9a-bis).

**These corrections only land if the user releases.** If `ACTION` ends up `fix-tickets` / `not-now` (Step 8), no release branch is created and `CHANGELOG_FIXES` cannot be committed — warn the user that the approved changelog corrections were *not* applied and they should fix them via `/code-changelog` (or a dedicated MR) on the originating branches. The findings stay in the terminal report and the Slack recap regardless.

## Step 7 — Post the recap on Slack

**Order is mandatory — Slack comes last, never first.** Compose and post the recap **only after Step 6 is fully complete**: every issue triaged, every "Créer un ticket" choice already delegated to `/glab-issue-create` and resolved to a real GitLab URL in the `<N> → ticket_url | "skipped"` map. The Slack messages are built *from* that completed map so every created ticket appears as a link directly in the header bullets and thread replies (see the linking rules below). **Never compose or post the recap before the tickets exist** — a recap posted mid-triage would carry bare issue summaries with no ticket links, defeating the purpose. (In CI mode no tickets are created — Step 6 is skipped — so the recap simply carries no ticket links; that's expected.)

**Skip entirely if `LOCAL_ONLY == "true"`.** Otherwise always post — `APPROVED` with no issues is also worth announcing, and CI runs need the visibility. The Slack post happens **before** the release-or-not decision (Step 8), so the team sees the verdict (and any created tickets) while the user is still deciding whether to proceed — even if the user then aborts the release.

In CI mode, this step is also the last meaningful action: post the recap, then exit with code:
- `0` if `APPROVED`
- `1` if `MODIFICATIONS REQUIRED` or `BLOCKED` (the CI job should fail loudly so the release pipeline blocks)

`project show slack.tech_channel_id` returns the repo's tech channel, or **empty** when the repo declares none (`project show` mirrors the config — no fallback). When `TECH_SLACK_CHANNEL_ID` is empty, **skip the Slack recap entirely** and warn the user that the repo has no tech channel configured (`slack.tech_channel_id` in its `.pysae-ai-tools.yaml`); never invent or hardcode a channel.

### Resolve the Slack token

Same pattern as `/slack-ask-review` — `SLACK_USER_TOKEN` locally (OAuth browser flow on first run), `SLACK_BOT_TOKEN` in CI:

```bash
if [ -n "${CI:-}" ]; then
  eval "$(pysae-ai-tools env resolve --set SLACK_BOT_TOKEN)"
else
  eval "$(pysae-ai-tools env resolve --set SLACK_USER_TOKEN)" \
    || eval "$(pysae-ai-tools env resolve --set SLACK_BOT_TOKEN)"
fi
```

If neither resolves, print a warning (`Slack token introuvable — recap non posté`) and stop here without failing the skill — the terminal report and tickets are already in place.

### Cancel any existing thread for the same release

A pre-release review can be re-run for the same source version + same project (e.g. the user fixed the flagged issues and wants a fresh audit). In that case, an older thread is already in the channel — its triage and decisions are stale and would confuse the team. **Find it and mark it cancelled before posting the new one.**

The lookup key is **`<PROJECT_URL>` + `<LATEST_TAG>`** only — the target `<NEW_VERSION>` is allowed to drift between runs (the user may have changed the bump). `find-thread` matches the recap by its **structured metadata** (stamped on the header at post time, see « Post the header »), with the header prefix kept as a fallback for recaps posted before the metadata existed:

```bash
# Same metadata the new header will carry — built with jq so values are escaped.
RECAP_META=$(jq -nc --arg p "$PROJECT_URL" --arg t "$LATEST_TAG" '{project: $p, tag: $t}')
PREFIX="*Review pre-release \`${LATEST_TAG}\` →"
CONTAINS="— \`${app}\`"

EXISTING=$(pysae-ai-tools slack find-thread \
  --channel "${TECH_SLACK_CHANNEL_ID}" \
  --metadata-event-type ai_tools_prerelease_recap \
  --metadata-match "$RECAP_META" \
  --prefix "$PREFIX" \
  --contains "$CONTAINS")

EXISTING_TS=$(echo "$EXISTING" | jq -r '.ts // empty')
```

If `EXISTING_TS` is non-empty, mark that thread's header as cancelled (the thread replies stay in place — they remain accessible via Slack's thread UI for anyone who wants the historical context):

```bash
pysae-ai-tools slack set-status-line \
  --channel "${TECH_SLACK_CHANNEL_ID}" \
  --ts "${EXISTING_TS}" \
  --status ":no_entry: annulé (nouvelle review demandée)"
```

Then proceed with the « Compose the messages » sub-step to post the brand-new thread.

Rules:
- **Both lookup criteria must match** (prefix AND contains) — never cancel a thread on prefix alone, otherwise a `v4.9.0 → v5.0.0` review for `pysae/api` could accidentally cancel a parallel `v4.9.0 → v4.10.0` review for `pysae/web`.
- **Only the most recent matching thread is cancelled.** `find-thread` returns the latest hit; older runs are ignored — they're already buried in the channel history and out of mind.
- **Best-effort, never crash.** If `find-thread` errors out or `set-status-line` fails (token missing scope, message too old to edit, etc.), log it and continue with the fresh post. A stale "en cours de validation" on a forgotten thread is not worse than no review at all.
- **Skip this sub-step entirely in CI mode** — CI runs once per pipeline, the duplicate case doesn't apply.

### Compose the messages (header + one thread reply per problème)

The recap is posted as a Slack **thread**: a single parent message carrying the header in the channel feed, then **one thread reply per problème** containing the full detail of that issue — same content as the terminal report, never a condensed summary.

**Slack mrkdwn only** — see [../slack-ask-review/SKILL.md § Slack mrkdwn formatting](../slack-ask-review/SKILL.md#slack-mrkdwn-formatting). Convert every `[label](url)` from the terminal report to `<url|label>` and every `**bold**` to `*bold*`.

**Mandatory linking rule** — every reference to a GitLab resource (issue, MR, commit, file) **must** be a Slack link `<url|label>`. Never write a bare `#1234`, bare SHA, or bare file path:
- Created ticket → `<https://gitlab.com/<group>/<project>/-/issues/<iid>|#<iid>>`
- Commit → `<https://gitlab.com/<group>/<project>/-/commit/<sha>|<sha:0:8>>`
- File → `<https://gitlab.com/<group>/<project>/-/blob/<sha>/<path>#L<line>|<path>:<line>>`

The local review number `#1`, `#2`, etc. is kept as plain `#1` (it's an in-report index, not a GitLab IID).

#### Header message

Counts come from the issue list: `C_COUNT` = number of `Critical`, `M_COUNT` = `Major`, `m_COUNT` = `Minor`.

Layout:

```
*Review pre-release `<LATEST_TAG>` → `<NEW_VERSION>`* — `<app>`
*Verdict* : <APPROVED | MODIFICATIONS REQUIRED | BLOCKED>

> Range : `<LATEST_TAG>..origin/<DEFAULT_BRANCH>` — <N> commits, <N> fichiers, +<add>/-<del>

*Problèmes* : <total> — :red_circle: <C_COUNT> Critical · :large_orange_diamond: <M_COUNT> Major · :large_yellow_circle: <m_COUNT> Minor
*Changelogs* : :white_check_mark: cohérents | :memo: <K> incohérence(s) — <kind counts>

• <severity_emoji> *#<N>* — <one-line summary> (<ticket_link>)
• <severity_emoji> *#<N>* — <one-line summary>
• <severity_emoji> *#<N>* — <one-line summary> (<ticket_link>)

*Status* : :hourglass_flowing_sand: en cours de validation

Détail des problèmes dans le thread :arrow_down:
```

**`*Changelogs*` line** — always include it, even on an empty review. `:white_check_mark: cohérents` when Step 4b found nothing; otherwise `:memo: <K> incohérence(s)` followed by a short kind breakdown (e.g. `1 manquant · 1 inexact`). This line reports what Step 4b **detected**; it is not rewritten later (the Slack recap is posted at Step 7, before any correction happens at Step 9). Whether the incoherences get corrected is confirmed by a follow-up reply once Step 9a-bis runs.

**`*Status*` line** — always include it on the very first header post, with the initial value `:hourglass_flowing_sand: en cours de validation`. This line is the only part of the header rewritten later (via `pysae-ai-tools slack set-status-line`) as the release moves through its stages: validation → release pipeline → deploy → prod. The full list of status transitions is documented in [Status lifecycle](#status-lifecycle) below.

**Per-problème summary line rules** :
- One bullet per problème, sorted Critical > Major > Minor (same order as the thread replies).
- The leading emoji must match the issue severity (`:red_circle:` / `:large_orange_diamond:` / `:large_yellow_circle:`) — same convention as the count line above and the thread replies.
- The summary is a **single sentence, ~120 characters max** (Slack wraps long lines but the goal is scannability — trim or rephrase if the issue title alone is too long).
- If a GitLab ticket was created for this problème in Step 6, append it **at the end of the line in parentheses** as a Slack link whose label is the GitLab IID: `(<https://gitlab.com/<group>/<project>/-/issues/<iid>|#<iid>>)`. Omit the parenthesised group entirely when the user picked "Ignorer" — no placeholder, no `(ignoré)`.

**No issues found** — replace the `*Problèmes*` line and the per-problème bullets and the thread pointer with a single line, and skip the per-issue replies:

```
:white_check_mark: Aucun problème détecté sur le diff.
```

The header stops here — **do not add an `*Action*` line**. The release-or-not decision happens *after* this Slack post (Step 8) and is never announced on Slack; `/ci-release` posts its own deploy-status messages once it runs.

#### Thread reply — one per problème

One thread reply per problème, in the same sorted order as the terminal report (Critical > Major > Minor, then discovery order). **Reproduce the full content of the issue block** as it appears in the terminal report — do not trim, do not summarize, do not drop the code snippet. The mrkdwn conversion (links, bold) is the only change.

Pick the severity emoji exactly like the header does:
- `Critical` → `:red_circle:`
- `Major` → `:large_orange_diamond:`
- `Minor` → `:large_yellow_circle:`

Layout for each reply:

```
<severity_emoji> *#<N>* — *<SEVERITY>* — <title>

<description of the problem, verbatim from the issue's `**Problème**` section in the terminal report — multi-line OK, no `*Problème*` label prefix>

*Correctif suggéré* : <prose description of the fix, plain text — short `inline code` snippets are fine for symbol names but no triple-backtick code block>

*Fichier* : <file_link>
*Commit* : <commit_link> — `<commit subject>`
*Nouveau ticket* : <ticket_link>
```

Layout rules:
- **Severity emoji** prefixes every reply header so each entry is immediately classifiable in the thread and stays consistent with the count line in the parent header.
- **Description goes directly under the header** — no `*Problème*` label, no bullet, just the prose.
- **`*Correctif suggéré*`** is written as plain prose on the same line as the label (or wrapped onto a few lines if needed). **No fenced code block.** If the source format in the terminal report uses a ` ``` ` block, rewrite the fix as a sentence describing what to change; reserve Slack `` `inline code` `` for symbol/function/file names inside that sentence.
- **Footer block (Fichier / Commit / Ticket) at the bottom**, after the `*Correctif suggéré*` line — context lives below the meat of the issue.
- **Omit the `*Nouveau ticket*` line entirely when no GitLab ticket was created** (Step 6 → user picked "Ignorer"). Never write `_ignoré_` or any placeholder — just drop the line. Same logic for `*Fichier*` and `*Commit*` if either link is unavailable for some reason: skip the line rather than emitting a partial entry.
- Long replies are fine; Slack threads are scrollable. The whole point is to ship the same level of detail as the terminal report, not a summary.

**Changelog coherence reply** — when Step 4b found at least one incoherence, post **one extra thread reply** (after the problème replies) reproducing the `### Cohérence des changelogs` block in Slack mrkdwn: a `:memo: *Cohérence des changelogs*` header, then one bullet per finding (kind, affected entry/change as a `<url|label>` link, proposed correction). Skip this reply when changelogs are coherent (the header's `*Changelogs*` line already says so). The *outcome* of these findings (corrected or not) is posted later as a separate follow-up reply by Step 9a-bis — at Step 7 the corrections have not run yet.

### Post the header, then each problème as a thread reply

Post the header first and capture its `ts`, then post each problème as a thread reply under that parent message using `--thread-ts`:

```bash
# Stamp the recap identity so the re-run lookup above finds this thread by
# metadata, not by matching the rendered header text. Reuse the same RECAP_META
# (jq-built {project, tag}) the find-thread step computes.
RECAP_META=$(jq -nc --arg p "$PROJECT_URL" --arg t "$LATEST_TAG" '{project: $p, tag: $t}')
HEADER_RESULT=$(pysae-ai-tools slack post-message \
  --channel "${TECH_SLACK_CHANNEL_ID}" \
  --metadata-event-type ai_tools_prerelease_recap \
  --metadata-payload "$RECAP_META" \
  --text "$HEADER_MSG")
HEADER_OK=$(echo "$HEADER_RESULT" | jq -r '.ok')
PARENT_TS=$(echo "$HEADER_RESULT" | jq -r '.ts // empty')

if [ "$HEADER_OK" != "true" ] || [ -z "$PARENT_TS" ]; then
  echo "Slack header post failed: $(echo "$HEADER_RESULT" | jq -r '.error')" >&2
  # Do not crash — Step 8 (decision), Step 9 (release notes), and Step 10 (release) still run.
else
  for ISSUE_MSG in "${ISSUE_MSGS[@]}"; do
    pysae-ai-tools slack post-message \
      --channel "${TECH_SLACK_CHANNEL_ID}" \
      --thread-ts "$PARENT_TS" \
      --text "$ISSUE_MSG" > /dev/null
  done
fi
```

Rules:
- **Always post replies in the same sorted order** as the terminal report (Critical first, then Major, then Minor).
- **Skip the reply loop entirely** when there are no issues (the header carries the `:white_check_mark: Aucun problème détecté` line and nothing else needs to be threaded).
- **`--text -`** with stdin is the safer pattern when the issue body is large or contains shell-meta characters — pass each `$ISSUE_MSG` via heredoc rather than positional arg if needed.
- **Best-effort** — if the header succeeds but one reply fails, log the error and continue with the remaining replies. Do not fail the skill (Step 8 still asks the decision, Steps 9/10 still run if confirmed).

After this step, keep `PARENT_TS` and `TECH_SLACK_CHANNEL_ID` available for the status updates in Step 8 (decision) and Step 10 (delegation to `/ci-release`).

### Status lifecycle

The `*Status*` line is the only mutable part of the header. It's rewritten in place via `pysae-ai-tools slack set-status-line` at four moments — three of which happen *after* this skill returns, inside `/ci-release` (Step 10 forwards `--slack-channel` + `--slack-status-ts` so the downstream skill can keep updating).

| When | New status line | Owner skill |
|------|-----------------|-------------|
| Just after the header is posted (« Post the header » sub-step of Step 7) | `:hourglass_flowing_sand: en cours de validation` | this skill |
| User picks "Lancer la release" / "Forcer la release" (Step 8) | `:arrows_counterclockwise: validé, création de la release en cours …` | this skill |
| `/ci-release` is about to invoke `/ci-deploy` for prod | `:rocket: validé, déploiement en cours` | `/ci-release` |
| `/ci-deploy` returns successfully and rollout is healthy | `:white_check_mark: validé, déployé en production` | `/ci-release` |

Additional terminal states (use whichever applies):

- A newer review run cancels this thread (« Cancel any existing thread » sub-step of Step 7 in a future invocation) → `:no_entry: annulé (nouvelle review demandée)`.
- User picks "Corriger les tickets" / "Pas maintenant" → `:no_entry_sign: release reportée — tickets à traiter` (set by this skill at Step 8 before stopping).
- Any failure inside `/ci-release` (release pipeline KO, missing tag, `/ci-deploy` failure, rollout critical errors) → `:x: échec du déploiement` (set by `/ci-release` on every exit-on-error path).
- Forced release on a `BLOCKED` verdict → keep `:rocket: validé, déploiement en cours` but prefix with `:warning:` (`:warning::rocket: …`).

To update the line, call the dedicated CLI — it fetches the message via `conversations.history`, swaps the line starting with `*Status* :`, and posts a `chat.update`:

```bash
pysae-ai-tools slack set-status-line \
  --channel "${TECH_SLACK_CHANNEL_ID}" \
  --ts "${PARENT_TS}" \
  --status ":arrows_counterclockwise: validé, création de la release en cours …"
```

If the update fails (token revoked, message too old, scope missing, etc.), log the error and continue — a stale status line is acceptable, a crashing skill is not. Status updates are always **best-effort**.

## Step 8 — Capture the final decision

Skip entirely in CI mode (Step 7 already posted the recap and exited).

Print a triage summary first so the user can re-check what's been triaged before deciding:
```
### Triage

- \#1 — Critical — `<title>` → <ticket url>
- \#2 — Major    — `<title>` → ignoré
- \#3 — Minor    — `<title>` → <ticket url>
```

If `CHANGELOG_FIXES` is non-empty, append a second list so the user sees the changelog corrections that **will be applied on the release branch** if they proceed (Step 9a-bis) — and that will be lost if they pick `fix-tickets` / `not-now`:
```
### Corrections changelog (appliquées si release)

- ajouter `* feat: <desc> (#<iid>)`
- reformuler `changelogs/<file>.md`
- supprimer `changelogs/<file>.md`
```

Then ask what to do next via `AskUserQuestion`. The Slack recap has already been broadcast (Step 7), so the team knows the verdict and the created tickets regardless of what the user picks below — including "Pas maintenant". **Default options depend on the verdict**:

**If `BLOCKED`** — never offer release-now. Options:
- **Corriger les tickets** *(Recommended)* — stop here so the user can switch to `/code-implement` on each ticket
- **Forcer la release quand même** — proceed to `/ci-release <app> <NEW_VERSION> --skip-review` despite the blockers

**If `MODIFICATIONS REQUIRED`** — both options, fixing first is recommended:
- **Corriger les tickets** *(Recommended)*
- **Lancer la release** — invoke `/ci-release <app> <NEW_VERSION> --skip-review`

**If `APPROVED` or no issues found**:
- **Lancer la release** *(Recommended)* — invoke `/ci-release <app> <NEW_VERSION> --skip-review`
- **Pas maintenant** — stop without releasing

**Capture the user's choice into an `ACTION` variable** for use in Steps 9 and 10 — one of `release`, `release-forced`, `fix-tickets`, `not-now`. Do not invoke `/code-release-notes` or `/ci-release` yet — Step 9 generates the user-facing notes, then Step 10 owns the `/ci-release` call.

### Refresh the Slack status

Right after capturing `ACTION` (and before delegating to `/ci-release`), update the header status line on Slack via `pysae-ai-tools slack set-status-line` — see [Status lifecycle](#status-lifecycle) for the full table. Pick the new status from `ACTION`:

- `release` or `release-forced` → `:arrows_counterclockwise: validé, création de la release en cours …` (prepend `:warning:` when `release-forced` on a `BLOCKED` verdict).
- `fix-tickets` or `not-now` → `:no_entry_sign: release reportée — tickets à traiter`. This is a **terminal** status: no further updates will happen since `/ci-release` won't be invoked.

```bash
pysae-ai-tools slack set-status-line \
  --channel "${TECH_SLACK_CHANNEL_ID}" \
  --ts "${PARENT_TS}" \
  --status "$NEW_STATUS"
```

Skip the update entirely if `PARENT_TS` is empty (the « Post the header » sub-step of Step 7 failed) or if `LOCAL_ONLY == "true"`. Errors are logged but never crash the skill.

## Step 9 — Create the release branch, then generate user-facing release notes

Skip in CI mode and skip if `ACTION` is `fix-tickets` or `not-now`.

The Pysae release flow always ships through a `release/${NEW_VERSION}` branch (see [../ci-release/SKILL.md § Step 5](../ci-release/SKILL.md)). Since this skill commits the release notes **before** delegating to `/ci-release`, it must also create the release branch first — otherwise the notes would land on the default branch, contradicting the "release branch is the working area" invariant.

### Step 9a — Create or reuse the release branch

Mirror `/ci-release` Step 5's normal/complete-mode path, cutting off `BASE_BRANCH` (the default branch, or the support branch in support mode — this skill never runs in hotfix mode, since `/ci-release` Step 3 skips the pre-release review for hotfixes). Use the same idempotency check so a re-run after a transient failure works :

```bash
RELEASE_BRANCH="release/${NEW_VERSION}"
git fetch origin "${BASE_BRANCH}"

if git ls-remote --exit-code --heads origin "${RELEASE_BRANCH}" >/dev/null 2>&1; then
  git fetch origin "${RELEASE_BRANCH}"
  BASE_SHA=$(git rev-parse "origin/${BASE_BRANCH}")
  REMOTE_SHA=$(git rev-parse "origin/${RELEASE_BRANCH}")
  if [ "$REMOTE_SHA" != "$BASE_SHA" ] \
     && ! git merge-base --is-ancestor "$BASE_SHA" "$REMOTE_SHA"; then
    echo "✗ ${RELEASE_BRANCH} has diverged from ${BASE_BRANCH} — resolve manually"
    exit 1
  fi
  git checkout -B "${RELEASE_BRANCH}" "origin/${RELEASE_BRANCH}"
else
  git checkout -B "${RELEASE_BRANCH}" "origin/${BASE_BRANCH}"
  git push -u origin "${RELEASE_BRANCH}"
fi
```

After this sub-step the working tree is on `${RELEASE_BRANCH}` so `/code-release-notes` Step 2 picks it up as the target branch.

### Step 9a-bis — Apply the approved changelog corrections

Skip if `CHANGELOG_FIXES` (from Step 6b) is empty. This **must run before Step 9b** so the corrected entries flow into the user-facing release notes, and it lands on `${RELEASE_BRANCH}` so the fixes ship with this release (the `changelogs/*.md` files are merged into `CHANGELOG.md` at tag time by `pysae-ai-tools code changelog release`).

Apply each fix through its sanctioned channel — never hand-write a new file:

- **`add`** — `pysae-ai-tools code changelog generate --write --type <t> --issue-iid <iid> "<english description>"`. **Always pass `--issue-iid` explicitly**: the branch is `release/${NEW_VERSION}`, so the auto-detection would otherwise grab the version digits as the IID. The generated filename (derived from the branch slug) is cosmetic — the release merge globs every `changelogs/*.md`, only the bullet content matters.
- **`reword` / `retype`** — `Edit` the target `changelogs/<file>.md`, keeping the exact `* <type>: <description> (#<iid>)` skeleton and changing only the wording and/or the `<type>`. This is the single sanctioned manual changelog edit (see [../code-changelog/SKILL.md § Editing an existing pending entry](../code-changelog/SKILL.md)); the skill's Edit hook permits it precisely because the path is under `changelogs/`. Never touch the `(#iid)` unless the fix is a `wrong-iid`.
- **`remove`** (orphan / duplicate) — `git rm changelogs/<file>.md`.

Then validate and commit the batch on the release branch:

```bash
git add -A changelogs/
pysae-ai-tools code changelog validate         # gate: must exit 0 before committing
git commit -m "docs(changelog): align pending entries with shipped changes (pre-release review)"
git push origin "${RELEASE_BRANCH}"
```

Entries written here are already canonical (`generate` emits the strict format, and reword/retype Edits keep the `* type: desc (#iid)` skeleton), so plain `validate` exits 0. If it instead reports a failure — a manual Edit broke the skeleton, or a reworded entry blew the 200-char budget — run `pysae-ai-tools code changelog validate --fix` to repair format slips (**note it exits non-zero whenever it rewrites a file, so re-run plain `validate` to confirm exit 0**), trim any over-budget bullet by hand, then commit. Never push entries that would fail the release pipeline's own validation.

**Then post the follow-up Slack reply** (skip if `LOCAL_ONLY == "true"`, `IS_CI == "true"`, or `PARENT_TS` is empty) under the same thread, confirming the outcome of each Step 4b finding:

```
:white_check_mark: *Corrections changelog appliquées sur* `release/<NEW_VERSION>`

• <kind> — <entry/change link> → :white_check_mark: corrigé (`* <type>: <desc> (#<iid>)`)
• <kind> — <entry/change link> → :fast_forward: ignoré
```

Best-effort: a failed Slack reply is logged, never fatal. Continue to Step 9b regardless.

### Step 9b — Generate release notes on the release branch

Delegate to `/code-release-notes` :

```
/code-release-notes <NEW_VERSION>
```

The delegated skill reads the same diff/changelog material we just reviewed, synthesises a French user-facing body, asks the user to validate the wording, then commits + pushes the nine files to **`${RELEASE_BRANCH}`** (not the default branch — `/code-release-notes` reads the current branch and pushes there).

Read its `RELEASE_NOTES_RESULT` block:
- `status: "pushed"` → continue to Step 10.
- `status: "cancelled"` → the user backed out of the release notes. Treat as if `ACTION = not-now` (do **not** trigger `/ci-release`) and refresh the Slack status to `:no_entry_sign: release reportée — tickets à traiter` (or a new reason if you want to add one). The release branch stays on origin — `/ci-release` Step 5's idempotency check will reuse it on a future run.
- `status: "failed"` → stop. Surface the failure to the user and let them re-run `/code-release-notes` after fixing the underlying issue (dirty tree, off-branch, push rejected, etc.). Do **not** proceed to Step 10.

This skill owns the release-notes step because it already iterated over every change in the diff at Step 3, so re-doing the synthesis inside `/ci-release` would just duplicate work and rerun the same LLM pass on the same inputs.

## Step 10 — Trigger the release

Skip in CI mode (Step 7 already exited). Skip if `ACTION` is `fix-tickets` or `not-now`.

If `ACTION` is `release` or `release-forced`, **now** invoke `/ci-release` with `<NEW_VERSION>`, `--skip-review`, `--skip-release-notes`, and the Slack status coordinates so it can keep updating the line as the release progresses. **In support mode, also pass `--support ${SUPPORT_LINE}`** so `/ci-release` cuts from / back-merges into the support branch and deploys mobile-only:

```
/ci-release <app> <NEW_VERSION> --skip-review --skip-release-notes --slack-channel <TECH_SLACK_CHANNEL_ID> --slack-status-ts <PARENT_TS>
# support: append --support <SUPPORT_LINE>
```

Omit `--slack-channel` and `--slack-status-ts` when `PARENT_TS` is empty (the « Compose the messages » sub-step of Step 7 failed) or when `LOCAL_ONLY == "true"` — `/ci-release` then just skips its own status updates.

`--skip-review` is the explicit anti-loop guard — it tells `/ci-release` Step 3 not to re-offer the pre-release review (which would re-invoke this skill and loop forever). `--skip-release-notes` tells `/ci-release` Step 6 not to re-run `/code-release-notes` since Step 9b already committed the release notes on `${RELEASE_BRANCH}`. The two flags are independent on purpose — passing them both is what tells `/ci-release` that *both* upstream steps have been taken care of. The positional `<NEW_VERSION>` is treated by `/ci-release` Step 4 as already-validated, so no version prompt either. `/ci-release` Step 5 will find `${RELEASE_BRANCH}` already on origin (ahead of the default branch by the release-notes commit) and reuse it via the idempotency check rather than recreating. Never bypass `/ci-release` for the actual rollout.

The Slack recap (Step 7) was already posted with the verdict and the triage state, so the team already knows whether a release was warranted. `/ci-release` owns the rest of the Slack status updates (`:rocket: déploiement en cours` → `:white_check_mark: déployé en production`) and its own deploy-status notifications.

## Rules

- **Read-only on the codebase** during Steps 1–8 — never edit source files; the only side effects of the review itself are GitLab tickets (via `/glab-issue-create`) and the Slack post (Step 7). `--local` disables both. The changelog *coherence check* (Step 4b) and *triage* (Step 6b) are read-only too — they only collect proposed corrections; nothing is written until Step 9a-bis.
- **Step 9 is the only mutating step.** When `ACTION` ∈ {`release`, `release-forced`}, Step 9a creates (or reuses) the `release/${NEW_VERSION}` branch on origin, Step 9a-bis applies the approved changelog corrections (`changelogs/` only — add via `code changelog generate`, reword/retype via `Edit`, remove via `git rm`) and commits them on that branch, and Step 9b delegates the release-notes commit to `/code-release-notes`. These are release-flow setup operations, not code review changes, and they honour the "release branch first, notes second" invariant shared with `/ci-release` Step 5/6.
- **Changelog edits are confined to `changelogs/`.** The skill's Edit hook blocks every other path; new entries always go through `pysae-ai-tools code changelog generate` (never hand-written), and the only sanctioned manual edit is rewording/retyping a pending entry in place.
- **Support mode reviews the support line, not main.** When `--support vX.Y.x` (or a `support/*` current branch) is given, `BASE_BRANCH` is the support branch: the diff range, changelog collection and Step 9a release branch all use it, the last tag is the highest **in-range** tag, and Step 10 forwards `--support` to `/ci-release` (which then back-merges into the support branch and deploys mobile-only, never the web).
- **Stay scoped to the diff** — do not lint the whole codebase, do not review unchanged files.
- **Never re-trigger `/code-review-pre-release` from itself** — once delegated to `/ci-release`, the release flow owns the rest.
- **French** for communication, English for commands and ticket titles.
- **Confirmation required** before launching `/ci-release` — never auto-release even if the verdict is `APPROVED`.
- **Tickets first, Slack last** — the Slack recap (Step 7) is always composed and posted *after* the full Step 6 triage, so every created ticket appears as a link in the message. Never post the recap before the tickets exist.
- **Slack post is best-effort** — a missing token or a failed `slack post-message` must not fail the skill. The terminal report and the tickets are the source of truth.
- **Never use MCP Slack tools** — go through `pysae-ai-tools slack …` so the same flow works locally (user token) and in CI (bot token).

$ARGUMENTS
