Skip to content
Back to skills

Release

BSecurity

Prepare release communication and check readiness. Main mode: notes with optional flags --changelog, --summary, --migration, --append (incremental: reruns the full pipeline scoped to newly-landed commits, integrating results into existing DRAFT.md/CHANGELOG.md/SUMMARY.md/MIGRATION.md instead of full regenerate — non-destructive except revert/pivot and the re-folded DRAFT.md Summary); range as v1->v2. Other modes: prepare (full pipeline: audit → all artifacts), audit (pre-release readiness: bl...

  • 28 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added May 27, 2026
data-aipythongoshellbashnodedebugginggitapidocumentation

Works with

  • terminal
  • api

Security analysis

B80/100
  • criticalSends environment variables or credentials to an external URL
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned October 3, 2026

npx -y skills add Borda/AI-Rig --skill release --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Release?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Release
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/borda-release/badge)](https://www.skillsdirectory.com/skills/borda-release)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: release
description: "Prepare release communication and check readiness. Main mode: notes with optional flags --changelog, --summary, --migration, --append (incremental: reruns the full pipeline scoped to newly-landed commits, integrating results into existing DRAFT.md/CHANGELOG.md/SUMMARY.md/MIGRATION.md instead of full regenerate — non-destructive except revert/pivot and the re-folded DRAFT.md Summary); range as v1->v2. Other modes: prepare (full pipeline: audit → all artifacts), audit (pre-release readiness: blockers, docs alignment, version consistency, CVEs), demo (story-telling release notebook in jupytext # %% format). TRIGGER when: user requests release notes, CHANGELOG entry, migration guide, internal summary, release readiness audit, release demo, or an incremental update to an already-drafted release; phrases: 'draft release notes', 'prepare release', 'audit release readiness', 'generate CHANGELOG for v1->v2', 'release demo notebook', 'update release notes with the latest commits'. SKIP: actual git tagging or PyPI/registry upload (use git tag, gh release create, twine upload directly); release communication for a non-Python project where this skill's pytest-centric audit assumptions do not apply; PR-level review (use /oss:review); thread/issue analysis (use /oss:analyse)."
argument-hint: '[notes] [v1->v2] [--changelog] [--summary] [--migration] [--append] | prepare <version> | audit [version] | demo [range]'
allowed-tools: Read, Write, Edit, Bash, TaskList, TaskCreate, TaskUpdate, Agent, AskUserQuestion, WebFetch
model: sonnet
effort: high
---

<objective>

Prepare release communication from changes. Output adapts to audience — user-facing notes, CHANGELOG entry, internal summary, migration guide.

**All outputs = documentation artifacts** (CHANGELOG.md, DRAFT.md, MIGRATION.md, SUMMARY.md, demo.py). Released product = code/package published separately via project tooling (`git tag`, `gh release create`, PyPI upload). Skill prepares communication; doesn't perform release.

NOT for ecosystem impact without release (use oss:analyse (requires `oss` plugin)). NOT for contributor communication or post-release announcements (use oss:shepherd (requires `oss` plugin)). NOT for retrospective analysis — historical review → oss:analyse (requires `oss` plugin). A request matching any NOT-for or SKIP case gets the redirect only: print `! UNSUPPORTED` naming the matching skill, then stop. Never do any part of that skill's work (thread summary, repro steps, duplicate check, or similar), before or after the redirect.

</objective>

<inputs>

Mode comes **first**; range or flags follow:

| Invocation | Arguments | Writes to disk |
| -- | -- | -- |
| `/release [notes] [range]` | optional range (default: last-tag..HEAD); use `v1->v2` for explicit range | `DRAFT.md` |
| `/release notes [range] --changelog` | optional range + flag | `DRAFT.md` + prepends `CHANGELOG.md` |
| `/release notes [range] --summary` | optional range + flag | `DRAFT.md` + `.temp/output-release-summary-<branch>-<date>.md` |
| `/release notes [range] --migration` | optional range + flag | `DRAFT.md` + `.temp/output-release-migration-<branch>-<date>.md` |
| `/release notes [range] --changelog --summary --migration` | all flags | All four outputs |
| `/release notes --append` | no range (derived from last-processed marker); compose with `--changelog`/`--summary`/`--migration` | Runs the full pipeline scoped to the incremental range, integrating results into every existing artifact **in place** (`DRAFT.md` always; `CHANGELOG.md`/`SUMMARY.md`/`MIGRATION.md` when their flag is set) instead of regenerating from scratch |
| `/release prepare <version>` | version to stamp, e.g. `v1.3.0` | All artifacts in `releases/<version>/`: `DRAFT.md` + `CHANGELOG.md` + `SUMMARY.md` + `MIGRATION.md` + `demo.py` + `waived-changes.md` |
| `/release audit [version]` | optional target version | Terminal readiness report; emits `verdict: READY \| NEEDS_ATTENTION \| BLOCKED` as final line for orchestrator consumption |
| `/release demo [range]` | optional range (default: last-tag..HEAD) | `releases/<version>/demo.py` or `.temp/release-demo-<branch>-<date>.py` |

Range notation: `v1->v2` (e.g. `v1.2->v2.0`) — converted internally to git range. No mode → defaults to `notes`. `prepare` = full pipeline — runs audit first, then all artifacts; use when cutting release, not drafting.

`--append`: assumes an earlier `notes` run already produced `DRAFT.md` (and, when their flags were used, `CHANGELOG.md`/`SUMMARY.md`/`MIGRATION.md`) and reruns the **full pipeline** — Gather changes through Draft executive summary, unchanged — scoped to only the commits landed since then, via a per-branch marker at `.temp/release-state-v2/<branch-key>/marker` (see `bin/release_append_marker.py`). No marker found (first use, or history rewritten by rebase/force-push) → falls back to the default `$LAST_TAG..HEAD` range and full-overwrite write, same as plain `notes` — establishing the baseline for the next `--append` run. Every successful `notes`-mode write (append or full) refreshes the marker to the completed range endpoint; an explicit plain-notes range ending before `HEAD` leaves later commits for a future append.

**Non-destructive except revert/pivot**: integration is purely additive — new bullets/blocks/paragraphs join existing artifacts without touching untouched content — except DRAFT.md's Summary, which every cycle re-folds (top-5 wins, re-derived upgrade call, one `New since last draft` pointer line), and *unless* this cycle's Classify/Truth-check phases detect that a new commit reverts or materially changes something a PRIOR cycle already wrote (see Gather changes' "Cross-cycle revert/pivot detection"); that stale entry is struck or superseded, never left stale alongside a contradicting new one.

- After merge, a **Post-merge re-validation** pass (see `modes/release-draft-template.md`) re-runs Truth check, Identify highlights re-ranking, Validate migration docs, and Validate docs against the FINAL merged content — catches prior-cycle content that went stale from THIS cycle's changes without being a clean detected revert/pivot (e.g. a Spotlight built on a commit a later cycle reverts). </inputs>

<workflow>

**Task hygiene** — task tools may be deferred; load before first use: `ToolSearch(query="select:TaskList,TaskCreate,TaskUpdate,TaskGet", max_results=4)`. Call `TaskList` first and triage each task it returns: `completed` if work clearly done, `deleted` if orphaned, keep `in_progress` only if genuinely continuing. Never spend a turn on bookkeeping alone — every `TaskCreate`/`TaskUpdate` ships in the same response as the next substantive tool call; one exception, `TaskUpdate(completed)` immediately before a long output block (`rules/task-lifecycle.md`).

**Task tracking** — create ALL tasks upfront, execute sequentially; mark completed as each phase finishes. After mode detection, mark inapplicable tasks `deleted`. **Zero bookkeeping-only turns**: issue every `TaskCreate` in one response together with the first real tool call, every mode-detection `deleted` in one response together with the next real call, and each phase's `completed` plus the next phase's `in_progress` in the response that carries that next phase's first call — never a response of task calls alone, except a `completed` right before a long output block:

- `demo` mode: mark deleted — Classify each change, Classify breaking changes, Validate migration docs, Audit changelog, Extract contributors, Draft migration guide, Draft executive summary, Write release draft, Post-merge re-validation
- bug-fix-only release (no 🚀 Added items): mark deleted — Generate release demo
- not `--append`, or `--append` with no valid marker (`$MARKER_VALID` computed false during Write release draft — see release-draft-template.md): mark deleted — Post-merge re-validation

Tasks:

- Gather changes (git log + find common base tag)
- Explore codebase (changed files, impl detail)
- Validate docs alignment
- Classify each change
- Classify breaking changes (codemap-gated; skip without index)
- Validate migration docs (skip when no migration doc found)
- Audit changelog
- Extract contributors
- Identify highlights
- Draft migration guide
- Generate release demo (feature releases only)
- Draft executive summary
- Write release draft
- Post-merge re-validation (`--append` merge only — re-runs Truth check, Identify highlights, Validate migration docs, Validate docs, and the Summary shape/duplication check against the final merged DRAFT.md; see release-draft-template.md "Post-merge re-validation")

**Sequential enforcement**: never begin phase until prior marked `completed`. On failure (empty range, git error, demo fail), stop and report — no downstream phases.

**Agent wait discipline — no polling, per-agent deadlines.** Every spawn (gather, changelog audit, adversarial review, shepherd) runs in the background: spawn, end the turn, resume on its completion notification. Never `ScheduleWakeup`, `ListAgents`, a `Monitor` loop, a `sleep` or a no-op call to wait. In each spawn response write `${TMPDIR:-/tmp}/release-setup-${CSID}/agent-watch-<batch>.tsv` with the Write tool (`<batch>` = `gather`, `changelog-audit`, `adversarial`, `shepherd`) — one row per agent, `<name><TAB><deliverable file, or - when it returns only an envelope><TAB>1800` — and at every wake-up run the check below once before reading agent output: `done` → consume; `timed_out`, or a notification that arrived without the deliverable → ⏱ `timed_out` now and follow that step's failure path. A ⏱ only informs; it never answers or skips a user question.

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
python "${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}/bin/agent_watch.py" --state-dir "${TMPDIR:-/tmp}/release-setup-${CSID}"  # timeout: 5000
```

**State checks — one call.** Ad-hoc repository state (branch, HEAD, upstream, status lists, remotes, worktrees, merge-base against a ref) is one `python "${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}/bin/git_state_snapshot.py"` call, never a run of separate `git status` / `branch` / `log -1` / `remote` calls; tag listing and range logs keep their own commands.

<!-- ARCH.md beside this file diagrams the runs, gates and parallel fan-out. Documentation only, never loaded — update it in the same commit as any change to step order, gate placement, or agent fan-out. -->

## Delegation strategy

In `prepare` and `audit` modes, delegate gather/explore/validate to subagent via file-based handoff (CLAUDE.md §2) — these phases produce large output, bloat main context:

1. Pre-compute gather file and waived-changes ledger paths, create dir:
   ```bash
   # BRANCH, DATE from Shared setup below
   GATHER_FILE=".temp/release-gather-$BRANCH-$DATE.md"
   mkdir -p .temp  # timeout: 5000
   export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
   WAIVED_FILE=$(mktemp ".temp/release-waived-$BRANCH-$DATE-XXXXXXXX") || exit 1  # one atomic, distinct ledger per invocation; preserve earlier runs
   echo "$WAIVED_FILE" > "${TMPDIR:-/tmp}/release-waived-${CSID}"  # persist for downstream reload (Check 41) — see modes/classify-truth-check.md "Waived changes ledger"
   ```
2. Assert variables before spawning:
   ```bash
   export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
   # reload SKILL_DIR, LAST_TAG (Check 41)
   IFS= read -r SKILL_DIR < "${TMPDIR:-/tmp}/release-setup-${CSID}/SKILL_DIR" 2>/dev/null || SKILL_DIR=""
   IFS= read -r LAST_TAG < "${TMPDIR:-/tmp}/release-setup-${CSID}/LAST_TAG" 2>/dev/null || LAST_TAG=""
   [ -n "$GATHER_FILE" ] && [ -n "$WAIVED_FILE" ] && [ -n "$REPO_ROOT" ] && [ -n "$RANGE" ] || { echo "Error: GATHER_FILE, WAIVED_FILE, REPO_ROOT, or RANGE is empty — verify Shared setup and Gather changes completed"; exit 1; }  # timeout: 5000
   # LAST_TAG empty is valid (first-ever release, no prior tag) — gather-prompt.md's baseline check handles it, never asserted here
   cat "$SKILL_DIR/templates/gather-prompt.md"  # timeout: 5000
   ```

> **Agent budget** — each spawn costs ~120,851 tok of fixed overhead (~73 tool-calls' worth) plus ~12.0 s/call, so work under ~73 calls is cheaper done inline: spawn nothing — work-displacement only; an isolation-motivated spawn (adversarial reviewer, distinct specialist role, model tier, worktree) runs regardless of size. Keep each agent near ~55 tool-calls; past ~60 they stall without returning an envelope, forcing reconstruction from disk. Every spawn prompt must require an envelope even on exhaustion — `partial: true` plus what was finished.

Template (loaded above). Substitute `<REPO_ROOT>`, `<RANGE>`, `<GATHER_FILE>`, `<WAIVED_FILE>`, `<LAST_TAG>` with literal values (`<LAST_TAG>` may substitute to an empty string — the template's own fallback covers that). Spawn:

> loads: gather-prompt.md `Agent(subagent_type="foundry:sw-engineer", prompt=<substituted gather-prompt.md content>)`

3. Validate envelope; every "abort" is a hard `exit 1`:
   ```bash
   STATUS=$(echo "$ENVELOPE" | jq -r '.status' 2>/dev/null)
   GATHER_FILE=$(echo "$ENVELOPE" | jq -r '.file' 2>/dev/null)
   BREAKING=$(echo "$ENVELOPE" | jq -r '.breaking // 0' 2>/dev/null)  # default 0 — never skip migration guide on missing field
   UNCONFIRMED=$(echo "$ENVELOPE" | jq -er 'if (.unconfirmed | type) == "number" and (.unconfirmed >= 0) and (.unconfirmed == (.unconfirmed | floor)) then .unconfirmed else error("invalid unconfirmed") end' 2>/dev/null) || { echo "Error: missing or invalid unconfirmed count" >&2; exit 1; }
   UNCONFIRMED_BREAKING=$(echo "$ENVELOPE" | jq -er 'if (.unconfirmed_breaking | type) == "number" and (.unconfirmed_breaking >= 0) and (.unconfirmed_breaking == (.unconfirmed_breaking | floor)) then .unconfirmed_breaking else error("invalid unconfirmed_breaking") end' 2>/dev/null) || { echo "Error: missing or invalid unconfirmed_breaking count" >&2; exit 1; }
   if [ "$STATUS" != "done" ] || [ -z "$GATHER_FILE" ] || [ "$GATHER_FILE" = "null" ] || [ ! -f "$GATHER_FILE" ]; then
       echo "Error: delegation validation failed — status=$STATUS, file=$GATHER_FILE" >&2
       exit 1
   fi
   ```

Validate the gather result against this invocation's ledger before any artifact phase. Each `unconfirmed` item has exactly one `REMOVED:` or `NET-STATE-ADD:` line; cross-cycle and post-merge entries use other prefixes. Persist the verified count for `prepare` Phase 6, which runs in a fresh shell:

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
IFS= read -r WAIVED_FILE < "${TMPDIR:-/tmp}/release-waived-${CSID}" 2>/dev/null || WAIVED_FILE=""
[ -n "$WAIVED_FILE" ] && [ -f "$WAIVED_FILE" ] || { echo "Error: waiver ledger missing after gather" >&2; exit 1; }
case "$UNCONFIRMED" in ''|*[!0-9]*) echo "Error: invalid unconfirmed count" >&2; exit 1;; esac
LEDGER_UNCONFIRMED=$(grep -Ec '^(REMOVED|NET-STATE-ADD):' "$WAIVED_FILE" || true)
[ "$LEDGER_UNCONFIRMED" -eq "$UNCONFIRMED" ] || { echo "Error: gather waiver count disagrees with ledger" >&2; exit 1; }
LEDGER_BREAKING=$(grep -Ec '^(REMOVED|NET-STATE-ADD): ⚠️ Breaking Changes:' "$WAIVED_FILE" || true)
[ "$LEDGER_BREAKING" -eq "$UNCONFIRMED_BREAKING" ] || { echo "Error: breaking waiver count disagrees with category ledger" >&2; exit 1; }
printf '%s\n' "$UNCONFIRMED" > "${TMPDIR:-/tmp}/release-unconfirmed-${CSID}"
```

If `$UNCONFIRMED_BREAKING` is nonzero, require review before any release artifact is written.

- Read this invocation's `$WAIVED_FILE` and show every `REMOVED:` or `NET-STATE-ADD:` entry marked ⚠️ Breaking Changes, with its old name, baseline/HEAD evidence, and proposed category.
- The typed envelope count and marked ledger lines must agree exactly at the gate above; a count alone is not item evidence.
- Invoke `AskUserQuestion` for each item: **Approve the proposed classification** or **Abort release preparation**.
- Proceed only after explicit approval of every item; an absent/ambiguous answer aborts.
- Do not describe the automatic reclassification as already user-confirmed.
- Apply this gate in both delegated `prepare`/`audit` runs before Phase 2b; inline `notes`/`demo`/`--append` runs use the same rule immediately after Truth check.

When `unconfirmed > 0`, surface removed/reclassified items as notification (not a gate — already resolved). Read the waived-changes ledger, not `$GATHER_FILE`:

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
IFS= read -r WAIVED_FILE < "${TMPDIR:-/tmp}/release-waived-${CSID}" 2>/dev/null || WAIVED_FILE=""
if [ "${UNCONFIRMED:-0}" -gt 0 ] 2>/dev/null && [ -n "$WAIVED_FILE" ] && [ -f "$WAIVED_FILE" ]; then
    WAIVED_ITEMS=$(grep -E '^(REMOVED|NET-STATE-ADD):' "$WAIVED_FILE" | head -20)  # timeout: 3000
    echo "Truth check removed/reclassified ${UNCONFIRMED} unverified claim(s) (category-specific HEAD/baseline check):"
    echo "$WAIVED_ITEMS"
fi
```

Pass `$GATHER_FILE` path to artifact phase — do NOT read gather file into main context. The ledger is a separate, smaller file — grepping it is not a gather-file read.

**Phases 5–6 parallel delegation** (`prepare`/`audit` modes, after phases 1–4 complete): Audit changelog and Extract contributors are independent — delegate concurrently to reclaim tokens.

Pre-compute output paths and persist for downstream reload (fresh shell, Check 41):

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
# fresh shell loses vars between blocks (Check 41)
IFS= read -r BRANCH < "${TMPDIR:-/tmp}/release-setup-${CSID}/BRANCH" 2>/dev/null || BRANCH=""
IFS= read -r DATE < "${TMPDIR:-/tmp}/release-setup-${CSID}/DATE" 2>/dev/null || DATE=""
IFS= read -r RANGE < "${TMPDIR:-/tmp}/release-range-${CSID}" 2>/dev/null || RANGE=""
IFS= read -r REPO_ROOT < "${TMPDIR:-/tmp}/release-setup-${CSID}/REPO_ROOT" 2>/dev/null || REPO_ROOT=""
IFS= read -r RELEASE_MODE < "${TMPDIR:-/tmp}/release-mode-${CSID}" 2>/dev/null || RELEASE_MODE="notes"
IFS= read -r VERSION < "${TMPDIR:-/tmp}/release-prepare-version-${CSID}" 2>/dev/null || VERSION=""
GATHER_FILE=".temp/release-gather-$BRANCH-$DATE.md"
[ -f "$GATHER_FILE" ] || { echo "Error: GATHER_FILE missing — phases 1–4 must complete first"; exit 1; }  # timeout: 5000
CHANGELOG_AUDIT_FILE=".temp/release-changelog-audit-$BRANCH-$DATE.md"
CONTRIBUTORS_FILE=".temp/release-contributors-$BRANCH-$DATE.md"
mkdir -p .temp  # timeout: 5000
echo "${CHANGELOG_AUDIT_FILE:-}" > "${TMPDIR:-/tmp}/release-changelog-audit-${CSID}"
echo "${CONTRIBUTORS_FILE:-}" > "${TMPDIR:-/tmp}/release-contributors-${CSID}"
# reload SKILL_DIR (Check 41)
IFS= read -r SKILL_DIR < "${TMPDIR:-/tmp}/release-setup-${CSID}/SKILL_DIR" 2>/dev/null || SKILL_DIR=""
cat "$SKILL_DIR/modes/changelog-audit-prompt.md"  # timeout: 5000
```

<!-- loads: modes/changelog-audit-prompt.md -->

Prompt (loaded above) — substitute `$RELEASE_MODE`, `$VERSION` along with the other listed variables, then execute (spawn Agent A + Agent B per instructions in that file).

Validate both envelopes:

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
python "${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}/bin/parse_release_envelopes.py" --envelope-a "$ENVELOPE_A" --envelope-b "$ENVELOPE_B"  # timeout: 5000
```

`$SCOPE_FLAGGED` > 0 → read the "Scope check" section of `$CHANGELOG_AUDIT_FILE`; every row there (critical = already-released commit landed a second time, medium = unreviewed branch merge) must reach the Findings summary table in `audit`/`prepare` modes — see `templates/audit-checks.md` Output, "Changelog scope" row — never left inside the audit file only. This is the check the reviewer needs surfaced before approving the PR, not something to bury in a report nobody opens. Same rule for the printed `unsourced` count: > 0 → copy each "unsourced entry" row from `$CHANGELOG_AUDIT_FILE` into the "Changelog evidence" row of the Findings summary (`templates/audit-checks.md`).

`notes` and `demo` modes: skip delegation — single-pass; run gather/explore/validate inline. **Size guard**: estimate commit count with `git rev-list --count ${RANGE:-${LAST_TAG:-HEAD~20}..HEAD} 2>/dev/null`. If >50, delegate to `foundry:sw-engineer` subagent same as prepare mode — inline gather with >50 commits causes context flood. Define `GATHER_FILE` before spawning so envelope-validation block above can resolve the path:

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
# reload BRANCH/DATE (Check 41)
IFS= read -r BRANCH < "${TMPDIR:-/tmp}/release-setup-${CSID}/BRANCH" 2>/dev/null || BRANCH=""
IFS= read -r DATE < "${TMPDIR:-/tmp}/release-setup-${CSID}/DATE" 2>/dev/null || DATE=""
   GATHER_FILE=".temp/release-gather-$BRANCH-$DATE.md"
   mkdir -p .temp  # timeout: 5000
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
WAIVED_FILE=$(mktemp ".temp/release-waived-$BRANCH-$DATE-XXXXXXXX") || exit 1  # one atomic, distinct ledger per invocation; preserve earlier runs
echo "$WAIVED_FILE" > "${TMPDIR:-/tmp}/release-waived-${CSID}"  # persist for Gather changes' cross-cycle detection + Truth check (Check 41)
```

## Mode Detection

| First token | MODE | Routing |
| -- | -- | -- |
| `prepare` | prepare | **Shared setup** first, then **Mode: prepare** |
| `audit` | audit | **Shared setup** first, then **Mode: audit** |
| `demo` | demo | **Shared setup** first, then **Mode: demo** |
| `notes` | notes | Strip `notes` token; parse flags and range from remainder |
| *(bare range or flag)* | notes | Parse flags and range from full string |
| *(none)* | notes | `RANGE=""`, no flags; run all phases |

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
eval "$(python "${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}/bin/parse-skill-flags.py" --flags changelog,summary,migration,append "$ARGUMENTS")"  # timeout: 5000
DO_CHANGELOG="$FLAG_CHANGELOG"; DO_SUMMARY="$FLAG_SUMMARY"; DO_MIGRATION="$FLAG_MIGRATION"; DO_APPEND="$FLAG_APPEND"
FIRST=$(echo "$CLEAN_ARGS" | awk '{print $1}')
# REST keeps the raw blob's tail — downstream readers of the sentinel expect flags intact
REST=""; case "$ARGUMENTS" in *" "*) REST="${ARGUMENTS#* }";; esac
echo "${REST:-}" > "${TMPDIR:-/tmp}/release-rest-${CSID}"
# strip mode token — else leaks into RANGE
_PARSE_INPUT="$CLEAN_ARGS"; case "$FIRST" in notes|prepare|audit|demo) case "$CLEAN_ARGS" in *" "*) _PARSE_INPUT="${CLEAN_ARGS#* }";; *) _PARSE_INPUT="";; esac;; esac
RANGE=$(echo "$_PARSE_INPUT" | grep -oE '[^ ]+([[:space:]]*->[[:space:]]*|\.\.)[^ ]+' | head -1 | tr -d '[:space:]')
for _a in $_PARSE_INPUT; do case "$_a" in --*) echo "⚠ unknown flag: $_a";; *) [ -z "$RANGE" ] && RANGE="$_a";; esac; done
RANGE="${RANGE/->/..}"
# persist (Check 41) — Gather-changes needs this for marker-based RANGE resolution
echo "${DO_APPEND}" > "${TMPDIR:-/tmp}/release-do-append-${CSID}"
echo "${DO_SUMMARY}" > "${TMPDIR:-/tmp}/release-do-summary-${CSID}"
echo "${DO_MIGRATION}" > "${TMPDIR:-/tmp}/release-do-migration-${CSID}"
case "$FIRST" in prepare|audit|demo) RELEASE_MODE="$FIRST";; *) RELEASE_MODE="notes";; esac
printf '%s\n' "$RELEASE_MODE" > "${TMPDIR:-/tmp}/release-mode-${CSID}"
```

<!-- branch: unsupported-flags — isolated; ≤1 call; fires only when unknown flags present -->

**Unknown flags**: if any `⚠ unknown flag:` lines printed above, invoke `AskUserQuestion` — (a) **Abort** (stop, re-invoke) · (b) **Continue ignoring**. On Abort: stop.

## Shared setup

Run this first — cold-start fallback (sets `$_OSS_SHARED`):

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
_OSS_SHARED=$(python "${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}/bin/resolve_shared_path.py" oss skills/_shared 2>/dev/null)  # timeout: 5000
# persist (Check 41)
echo "${_OSS_SHARED:-}" > "${TMPDIR:-/tmp}/release-oss-shared-${CSID}"
# loads: oss-shared-resolver.md
```

Extracted to `bin/release_setup.py` — resolves `SKILL_DIR`, `REPO_ROOT`, portable scratch `BRANCH`, raw `BRANCH_REF`, persistent `BRANCH_KEY`, `DATE`, `LAST_TAG`, `CHERRY_PICK_SUBJECTS`, `SOURCE_TAG_REF`. It refuses detached HEAD before publishing branch state. Writes each var under `${TMPDIR:-/tmp}/release-setup-${CSID}/`; stable-branch banner and "no stable tag" warnings go to stderr.

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
python "${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}/bin/release_setup.py" || exit 1  # timeout: 10000
IFS= read -r SKILL_DIR < "${TMPDIR:-/tmp}/release-setup-${CSID}/SKILL_DIR" 2>/dev/null || SKILL_DIR=""
IFS= read -r REPO_ROOT < "${TMPDIR:-/tmp}/release-setup-${CSID}/REPO_ROOT" 2>/dev/null || REPO_ROOT=""
IFS= read -r BRANCH < "${TMPDIR:-/tmp}/release-setup-${CSID}/BRANCH" 2>/dev/null || BRANCH=""
IFS= read -r BRANCH_REF < "${TMPDIR:-/tmp}/release-setup-${CSID}/BRANCH_REF" 2>/dev/null || BRANCH_REF=""
IFS= read -r BRANCH_KEY < "${TMPDIR:-/tmp}/release-setup-${CSID}/BRANCH_KEY" 2>/dev/null || BRANCH_KEY=""
IFS= read -r RELEASE_MODE < "${TMPDIR:-/tmp}/release-mode-${CSID}" 2>/dev/null || RELEASE_MODE=""
IFS= read -r DATE < "${TMPDIR:-/tmp}/release-setup-${CSID}/DATE" 2>/dev/null || DATE=""
IFS= read -r LAST_TAG < "${TMPDIR:-/tmp}/release-setup-${CSID}/LAST_TAG" 2>/dev/null || LAST_TAG=""
IFS= read -r CHERRY_PICK_SUBJECTS < "${TMPDIR:-/tmp}/release-setup-${CSID}/CHERRY_PICK_SUBJECTS" 2>/dev/null || CHERRY_PICK_SUBJECTS=""
IFS= read -r SOURCE_TAG_REF < "${TMPDIR:-/tmp}/release-setup-${CSID}/SOURCE_TAG_REF" 2>/dev/null || SOURCE_TAG_REF=""
[ -z "$REPO_ROOT" ] && { echo "Error: release_setup.py failed — REPO_ROOT empty; verify oss plugin installation"; exit 1; }
[ -n "$BRANCH_REF" ] && [ -n "$BRANCH_KEY" ] && [ -n "$RELEASE_MODE" ] || { echo "Error: release branch identity or mode missing" >&2; exit 1; }
if [ "$RELEASE_MODE" = notes ]; then
    IFS= read -r DO_APPEND < "${TMPDIR:-/tmp}/release-do-append-${CSID}" 2>/dev/null || DO_APPEND="false"
    if [ -f ".temp/release-state-v2/$BRANCH_KEY/journal.json" ]; then
        python "${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}/bin/release_append_publish.py" recover --branch "$BRANCH" || exit 1
        echo "Reconciled interrupted append publication; inspect marker and artifacts, then stop this invocation before gathering again."
        exit 0
    fi
    python "${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}/bin/release_append_marker.py" guard --branch "$BRANCH_REF" || exit 1
fi
```

<!-- branch: no-stable-tags — isolated; ≤1 call; fires only when repo has no stable git tags -->

When no stable tags exist, `LAST_TAG` resolves to initial commit — surface via `AskUserQuestion` ("No stable tags found. Range base is initial commit — proceed?"). Options: (a) Proceed with initial commit as base · (b) Abort — stop release process. On (b): stop, print "Release aborted — no stable tags found; create a tag first with `git tag v0.1.0`" and exit.

## Gather changes

Find common base tag across ALL branches via `git tag --list` sorted by version, then `git merge-base HEAD <tag-commit>`. Use as range lower bound when current branch has no direct tag ancestry.

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
# reload Shared-setup vars (Check 41)
IFS= read -r LAST_TAG < "${TMPDIR:-/tmp}/release-setup-${CSID}/LAST_TAG" 2>/dev/null || LAST_TAG=""
IFS= read -r CHERRY_PICK_SUBJECTS < "${TMPDIR:-/tmp}/release-setup-${CSID}/CHERRY_PICK_SUBJECTS" 2>/dev/null || CHERRY_PICK_SUBJECTS=""
IFS= read -r BRANCH < "${TMPDIR:-/tmp}/release-setup-${CSID}/BRANCH" 2>/dev/null || BRANCH=""
IFS= read -r BRANCH_REF < "${TMPDIR:-/tmp}/release-setup-${CSID}/BRANCH_REF" 2>/dev/null || BRANCH_REF=""
IFS= read -r BRANCH_KEY < "${TMPDIR:-/tmp}/release-setup-${CSID}/BRANCH_KEY" 2>/dev/null || BRANCH_KEY=""
IFS= read -r DO_APPEND < "${TMPDIR:-/tmp}/release-do-append-${CSID}" 2>/dev/null || DO_APPEND="false"
IFS= read -r RELEASE_MODE < "${TMPDIR:-/tmp}/release-mode-${CSID}" 2>/dev/null || RELEASE_MODE=""
if [ -z "$RANGE" ] && [ "$DO_APPEND" = "true" ]; then
    # marker present+valid -> "<sha>..HEAD"; else "$LAST_TAG..HEAD" (non-append default)
    RANGE=$(python "${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}/bin/release_append_marker.py" resolve --branch "$BRANCH_REF" --last-tag "$LAST_TAG")  # timeout: 5000
else
    RANGE="${RANGE:-$LAST_TAG..HEAD}"
fi
[ -z "$RANGE" ] && echo "Error: could not determine commit range" && exit 1
if [ "$RELEASE_MODE" = notes ]; then
FROZEN_HEAD=$(git rev-parse --verify 'HEAD^{commit}' 2>/dev/null) || { echo "Error: notes HEAD unresolved" >&2; exit 1; }
FROZEN_BRANCH=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) || { echo "Error: notes branch unresolved" >&2; exit 1; }
[ -n "$BRANCH_REF" ] && [ "$FROZEN_BRANCH" = "$BRANCH_REF" ] || { echo "Error: notes branch changed after release setup" >&2; exit 1; }
FROZEN_TAGS_FILE="${TMPDIR:-/tmp}/release-append-tags-${CSID}"
git for-each-ref --sort=refname --format='%(refname) %(objectname)' refs/tags > "$FROZEN_TAGS_FILE" || { echo "Error: notes tag refs unresolved" >&2; exit 1; }
case "$RANGE" in *...*|*..*..*) echo "Error: notes range must have one two-dot endpoint" >&2; exit 1;; *..*) ;; *) echo "Error: notes range must have one two-dot endpoint" >&2; exit 1;; esac
RANGE_START="${RANGE%..*}"
RANGE_END="${RANGE##*..}"
FROZEN_START=$(git rev-parse --verify "$RANGE_START^{commit}" 2>/dev/null) || { echo "Error: notes range start unresolved" >&2; exit 1; }
FROZEN_END=$(git rev-parse --verify "$RANGE_END^{commit}" 2>/dev/null) || { echo "Error: notes range endpoint unresolved" >&2; exit 1; }
git merge-base --is-ancestor "$FROZEN_START" "$FROZEN_END" || { echo "Error: notes range start is not an ancestor of endpoint" >&2; exit 1; }
git merge-base --is-ancestor "$FROZEN_END" "$FROZEN_HEAD" || { echo "Error: notes range endpoint is not on gathered HEAD" >&2; exit 1; }
if [ "$DO_APPEND" = "true" ]; then
    [ "$FROZEN_END" = "$FROZEN_HEAD" ] || { echo "Error: append range does not end at frozen HEAD" >&2; exit 1; }
fi
if [ "$(python "${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}/bin/release_append_marker.py" is-valid --branch "$BRANCH_REF" --last-tag "$LAST_TAG")" = true ]; then
    IFS= read -r SAVED_MARKER < ".temp/release-state-v2/$BRANCH_KEY/marker" || { echo "Error: saved marker unreadable" >&2; exit 1; }
    if [ "$DO_APPEND" = "true" ]; then
        [ "$FROZEN_START" = "$SAVED_MARKER" ] || { echo "Error: append range does not start at saved marker" >&2; exit 1; }
    else
        git merge-base --is-ancestor "$FROZEN_START" "$SAVED_MARKER" || { echo "Error: plain notes range skips commits after saved marker" >&2; exit 1; }
        git merge-base --is-ancestor "$SAVED_MARKER" "$FROZEN_END" || { echo "Error: plain notes range ends before saved marker" >&2; exit 1; }
    fi
fi
RANGE="$FROZEN_START..$FROZEN_END"
printf '%s\n' "$FROZEN_HEAD" > "${TMPDIR:-/tmp}/release-append-head-${CSID}"
printf '%s\n' "$FROZEN_START" > "${TMPDIR:-/tmp}/release-append-start-${CSID}"
printf '%s\n' "$FROZEN_END" > "${TMPDIR:-/tmp}/release-append-end-${CSID}"
printf '%s\n' "$FROZEN_BRANCH" > "${TMPDIR:-/tmp}/release-append-branch-${CSID}"
fi
if [ "$DO_APPEND" = "true" ] && [ "$(git rev-list --count "$RANGE" 2>/dev/null)" = 0 ]; then
    echo "No new commits to append; release artifacts unchanged. Stop this invocation."
    exit 0
fi
# persist (Check 41)
echo "${RANGE:-}" > "${TMPDIR:-/tmp}/release-range-${CSID}"

# quote RANGE — tags may carry unusual chars (e.g. v1.2-rc.1+build.42)
git log "$RANGE" --oneline --no-merges # timeout: 3000
git log "$RANGE" --no-merges --format="--- %H%n%B" # timeout: 3000
git diff --stat "$(echo "$RANGE" | sed 's/\.\.\./\ /;s/\.\./\ /')" # timeout: 3000

# prefer gh; fallback git remote show origin; never hardcode main
TRUNK=$(gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null)  # timeout: 6000
if [ -z "$TRUNK" ]; then
    TRUNK=$(git remote show origin 2>/dev/null | grep 'HEAD branch' | { read -r _ _ val; echo "$val"; })  # timeout: 5000
fi
PR_FIELDS="number,title,body,labels,mergedAt,author"
if [ -n "$TRUNK" ]; then
    PRS=$(gh pr list --state merged --base "$TRUNK" --limit 500 --json "$PR_FIELDS") || { echo "Error: gh pr list failed; cannot classify release PRs" >&2; exit 1; }  # timeout: 15000
else
    echo "⚠ Could not detect default branch — listing all merged PRs"
    PRS=$(gh pr list --state merged --limit 500 --json "$PR_FIELDS") || { echo "Error: gh pr list failed; cannot classify release PRs" >&2; exit 1; }  # timeout: 15000
fi
PR_COUNT=$(printf '%s' "$PRS" | jq 'length') || { echo "Error: could not count gh pr list results" >&2; exit 1; }
if [ "$PR_COUNT" -eq 500 ]; then
    echo "⚠ gh pr list reached its 500-item cap; recovering the complete merged-PR history"
    PR_API_ARGS=(--method GET --paginate --slurp 'repos/{owner}/{repo}/pulls' -f state=closed -f per_page=100)
    if [ -n "$TRUNK" ]; then
        PR_API_ARGS+=(-f "base=$TRUNK")
    fi
    PRS=$(gh api "${PR_API_ARGS[@]}") || { echo "Error: exhaustive PR recovery failed; do not claim release coverage is complete" >&2; exit 1; }  # timeout: 60000
    PRS=$(printf '%s' "$PRS" | jq '[.[][] | select(.merged_at != null) | {number, title, body, labels, mergedAt: .merged_at, author: .user}]') || { echo "Error: could not normalize exhaustive PR recovery" >&2; exit 1; }
fi
printf '%s\n' "$PRS"
```

Cross-reference commit bodies against PR descriptions — canonical source of truth for *why* change made. `BREAKING CHANGE:` footer = breaking change regardless of PR label.

The default-branch list is discovery context, not the contributor inventory. Query every candidate commit independently of the default branch so maintenance-branch and squash-only PR authors cannot disappear:

```bash
# Commit association follows the release range, including maintenance-branch merges.
COMMIT_SHAS=$(git rev-list "$RANGE") || { echo "Error: release candidate enumeration failed" >&2; exit 1; }
while IFS= read -r CANDIDATE_SHA; do
    [ -z "$CANDIDATE_SHA" ] && continue
    PR_ASSOCIATIONS=$(gh api --method GET --paginate --slurp "repos/{owner}/{repo}/commits/$CANDIDATE_SHA/pulls" -f per_page=100) || { echo "Error: PR association coverage unavailable for $CANDIDATE_SHA; retain gap, do not claim complete credits" >&2; exit 1; }  # timeout: 15000
    printf '%s' "$PR_ASSOCIATIONS" | jq --arg sha "$CANDIDATE_SHA" '{commit: $sha, pull_requests: [.[][] | select(.merged_at != null) | {number, title, author: .user, base: .base.ref, merge_commit_sha}]}' || { echo "Error: invalid PR association evidence" >&2; exit 1; }
done <<< "$COMMIT_SHAS"
```

Retain each commit's association result, including empty results, in gathered evidence. Fetch the associated PR's metadata when needed to verify repository and commit/diff membership; do not discard an association because its base is not the default branch. Empty successful lookup means no associated merged PR was returned, not a fabricated author; keep Git authors/coauthors. Failed lookup, deleted author, or ambiguous association remains an explicit gap. Do not silently continue with the default-branch list after an association failure.

Retain verified commit-to-PR associations and each in-range PR author's known login/display name for Extract contributors, including PR-only authors of maintainer-authored squash commits. Preserve this identity inventory through classification, including grouped or net-zero changes; record missing association/identity metadata as contributor-coverage gaps. When delegated, write this evidence to `$GATHER_FILE`; merge dates and membership in the collected PR list alone do not establish release-range membership.

**Detect revert pairs**: scan `git log $RANGE --no-merges --format="%H %s"` for revert subjects — those beginning with `Revert "` (git default) or a conventional `revert:` / `revert(<scope>):` type, case-insensitive. For each: match the original by the body's `This reverts commit <sha>` trailer first; no trailer → extract the original subject (the quoted text, or the text after the type prefix) and search the range for it. Both found → `REVERT_SET` pair (net effect zero).

Record all `REVERT_SET` pairs before Classify. Commits in `REVERT_SET` excluded from standard sections; collected for 🔄 Reverted. If only revert is in range (original predates range) → classify as ✗ Removed (or ⚠ Breaking Changes if API surface changed without prior deprecation) — NOT 🔄 Reverted; net user effect is non-zero.

**Cross-cycle revert/pivot detection** (`--append` only, when a prior `DRAFT.md`/`$CHANGELOG_FILE` exists — extends the "non-destructive except revert/pivot" rule across append cycles, not just within one range).

**Two detection paths — patch-id provenance (deterministic, content-stable) for literal reverts, semantic judgment (best-effort) for pivots.**

- Every prior cycle's write is recorded in `.temp/release-state-v2/$BRANCH_KEY/provenance.jsonl` (see `<notes>` "Provenance store (patch-id keyed)"): one entry per `(patch-id, artifact, exact written text)` tuple, keyed on `git patch-id --stable` output rather than raw commit sha — a bare sha changes on `--amend`, `rebase`, or `cherry-pick` even when the diff itself is untouched, so a raw-sha key would silently miss a revert of a commit that has since been reworded or cherry-picked; `patch-id` is a normalized hash of the diff content and survives all three.
- A genuine `git revert` commit always carries git's own auto-generated `This reverts commit <sha>.` trailer in its body — that sha identifies the reverted commit's *current* form, turned into its patch-id and looked up in the store (no text-matching involved).
- A **pivot** (a symbol's classification changes without a literal revert commit — e.g. deprecated this cycle after being added in a prior one) has no such trailer to key off; it still needs the model's semantic judgment, same as before.
- A revert commit *without* a usable trailer (manual revert, not made via `git revert`) also falls back to the semantic path — as does a trailer sha whose diff produces no stable patch-id (see step 2 below).

1. **Read** the current `DRAFT.md` Notable-changes bullets and `$CHANGELOG_FILE`'s Unreleased section into context (Read tool — actual content, not just piped grep output).
2. **Revert case — patch-id lookup first**: for each revert whose original predates `$RANGE` (the "only revert in range" case above), extract the original sha from the revert commit's own trailer, then convert it to a patch-id — the sha itself is never the lookup key, since it may point at a commit reworded/rebased/cherry-picked since it was recorded, but its diff content (hence patch-id) is unaffected by any of those:
   ```bash
   ORIGINAL_SHA=$(git log -1 --format=%B "<revert-commit-sha>" | grep -oE 'This reverts commit [0-9a-f]{40}' | grep -oE '[0-9a-f]{40}')  # timeout: 3000
   [ -n "$ORIGINAL_SHA" ] && ORIGINAL_PID=$(git show "$ORIGINAL_SHA" | git patch-id --stable | awk '{print $1}')  # timeout: 3000
   # ORIGINAL_PID empty despite found sha → merge commit w/o -m, or empty commit — rare, treat as "no trailer" below
   ```
   Trailer found and patch-id non-empty → look it up:
   ```bash
   export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
   IFS= read -r BRANCH_KEY < "${TMPDIR:-/tmp}/release-setup-${CSID}/BRANCH_KEY" 2>/dev/null || BRANCH_KEY=""
   PROVENANCE_FILE=".temp/release-state-v2/$BRANCH_KEY/provenance.jsonl"
   LEGACY_PROVENANCE=".temp/release-state-v2/$BRANCH_KEY/provenance.json"
   [ ! -f "$PROVENANCE_FILE" ] || jq -c --arg pid "$ORIGINAL_PID" 'select(.patch_id == $pid)' "$PROVENANCE_FILE"
   [ ! -f "$LEGACY_PROVENANCE" ] || jq -c --arg pid "$ORIGINAL_PID" '.[] | select(.patch_id == $pid)' "$LEGACY_PROVENANCE"  # timeout: 3000
   ```
   One or more matches → each is a confirmed `CROSS_CYCLE_MATCH` directly (step 4) — a patch-id match is definitive, no semantic confirmation needed, even when the matched record's stored `sha` differs from `$ORIGINAL_SHA` (expected whenever the original commit was reworded/rebased/cherry-picked since it was recorded — the diff, not the sha, is what's being matched). No matches → the reverted commit predates any drafted artifact (already published, or never drafted), or its diff has genuinely changed since — falls through to normal ✗ Removed/⚠ Breaking classification, same as today. No trailer found (manual/non-`git revert` revert commit), or trailer found but patch-id computation produced empty output → fall through to step 3's grep-narrow-then-confirm path for this revert, same as a pivot.
3. **Pivot case (and any revert without a usable trailer) — semantic judgment, best-effort not a guarantee**: grep is only a *narrowing hint*, never the decision.
   - Grep for the de-`Revert`-wrapped subject/PR title (revert-without-trailer) or the changed symbol name (pivot: a newly-classified ⚠ Breaking Changes / 🌱 Changed / 🗑️ Deprecated / ❌ Removed item).
   - A grep hit is a candidate to inspect — never an automatic match; a bare substring (e.g. `run`, `Config`) can hit unrelated bullets, treat every hit as "maybe."
   - **Confirm semantically**: does the candidate line genuinely describe the same feature/symbol this new commit reverts or supersedes? Only a positive judgment call proceeds to step 4.
   - Known limitation, accepted: a prior cycle's bullet reworded into human prose by `oss:shepherd` can defeat this grep hint — e.g. raw subject `Revert "feat: add ConfigLoaderV2"` vs. shepherded bullet "dropped the legacy config loader."
   - Don't chase this with fuzzier matching, which only trades false-negatives for false-positives — the patch-id path above already removes this failure mode for the common case of a real `git revert`.
4. **Record** `CROSS_CYCLE_MATCH: {artifact: <file>, matched_text: <exact current line/bullet, copied verbatim>, via: "patch_id"|"semantic"}` — matched_text must always be a full existing line/bullet, **never a bare symbol/subject substring**, so the Edit-tool strike (see `modes/release-draft-template.md` "Append merge") stays scoped to the one confirmed entry instead of risking a match on every bullet that happens to contain the token.
   - Treat as **net-state removal** — same Net-state principle as within-range `REVERT_SET`, just spanning cycles: the reader never saw the reverted/superseded content ship in a published release, so it vanishes from both artifacts entirely, no redundant ✗ Removed/⚠ Breaking bullet added.
   - This detection runs in the orchestrator, not a subagent — append the entry to the ledger directly, same reload as `SKILL.md`'s "waived changes ledger" sentinel:
   ```bash
   export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
   IFS= read -r WAIVED_FILE < "${TMPDIR:-/tmp}/release-waived-${CSID}" 2>/dev/null || WAIVED_FILE=""
   [ -n "$WAIVED_FILE" ] && echo "CROSS_CYCLE_MATCH: $MATCHED_TEXT — superseded, struck from $ARTIFACT" >> "$WAIVED_FILE"  # timeout: 3000
   ```

No patch-id match, no candidate found, or a candidate inspected but not confirmed → proceed as a normal, purely-additive item (default — never manufacture a match speculatively). A stale bullet surviving the semantic path is an accepted best-effort gap, not silent data loss — the Semantic consistency review pass (`modes/release-draft-template.md`, runs before every write) is the last line of defense that can still catch a surviving contradiction. Collect all `CROSS_CYCLE_MATCH` entries for Audit changelog and Write release draft to consume.

## Explore codebase

For top 3–5 significant changes (features, breaking, major behavior), read actual diff or changed files:

```bash
git diff "$RANGE" -- <file>    # timeout: 3000
git show <commit>:<file>       # timeout: 3000
```

Goal: understand new APIs, parameters, behavior — notes describe real functionality, not just commit subjects. Skip trivial changes (typos, dep bumps, CI config).

## Validate docs

Check public API surface in docs/ (or README) matches diff. Flag public symbol added/renamed/removed in Gather changes but absent from docs. Report: `- [MISSING/STALE] <symbol> in <doc-file>`. Empty list = docs aligned.

**Doc weight check** — for each 🚀 Added change identifying significant new entity (new public skill, new command, new agent, new submodule, new mode): compute **doc weight** for that feature and 2–3 comparable existing features of same nature in relevant README or docs file.

Doc weight = `header_score + coverage_score + example_score`:

- `header_score`: H2 = 3, H3 = 2, H4/deeper = 1, no heading = 0
- `coverage_score`: `min(non_blank_lines_in_section / 5, 5)` — lines from feature heading to next same-or-higher heading
- `example_score`: fenced code blocks in section, capped at 3

Weight ratio = `new_feature_weight / mean(comparable_weights)`. Flag UNDERTREATED when ratio < 0.5. Report: `- [UNDERTREATED] <feature> in <doc-file> — weight N vs peers M1/M2 (ratio R)`. Collect as `doc_proportionality` list in findings.

## Classify each change

<!-- loads: modes/classify-truth-check.md (single load — Classify/Truth check/Breaking-change classification below run sequentially in this same path, no branch skips an earlier phase while running a later one; file content stays in context for the two "Follow above" refs below) -->

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
# reload SKILL_DIR (Check 41)
IFS= read -r SKILL_DIR < "${TMPDIR:-/tmp}/release-setup-${CSID}/SKILL_DIR" 2>/dev/null || SKILL_DIR=""
cat "$SKILL_DIR/modes/classify-truth-check.md"  # timeout: 5000
```

Follow above and execute. Contains: category table, PR accumulation rules, dedup rules, OMIT-INTERNAL body-signal override, cherry-pick annotation.

## Truth check

Follow `modes/classify-truth-check.md` (Truth check section, loaded above) and execute. Gate: runs after Classify, before Audit changelog. Verify 🚀 Added / 🌱 Changed final names at HEAD; verify ❌ Removed old names exist at `$LAST_TAG` and are absent at HEAD; check ⚠ Breaking Changes against the baseline before relabeling. Max 3 loop iterations.

## Breaking-change classification

Follow `modes/classify-truth-check.md` (Breaking-change classification section, loaded above) and execute. Codemap-gated (skips without a v3 index). For each diff-derived public symbol, `fn-rdeps --exclude-tests` labels it Breaking (caller outside its own package) or internal; Breaking symbols move to ⚠ Breaking Changes with caller evidence, and `migration_lines` feed the Draft migration guide as `breaking_callers` findings.

Apply the **post-promotion baseline and item-approval gate** in that section after codemap output and before Audit changelog or any artifact write. Earlier Truth check and the delegated gather envelope cannot approve a later Added/Changed → Breaking promotion.

## Validate migration docs

Gate — runs after Truth check. Only when project has migration docs page.

**Detect** — migration doc OR any alternative describing API changes between versions:

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
# reload REPO_ROOT (Check 41)
IFS= read -r REPO_ROOT < "${TMPDIR:-/tmp}/release-setup-${CSID}/REPO_ROOT" 2>/dev/null || REPO_ROOT=""
MIGRATION_DOC=$(find "$REPO_ROOT" -maxdepth 3 \( \
  -iname "MIGRATION*" -o -iname "UPGRADING*" -o \
  -iname "migration.md" -o -iname "upgrading.md" -o \
  -iname "CHANGELOG*" -o -iname "BREAKING*" -o \
  -iname "api-changes*" -o -iname "release-notes*" \
\) -not -path "*/node_modules/*" -not -path "*/.venv/*" -not -path "*/.git/*" \
| head -1)  # timeout: 5000
[ -z "$MIGRATION_DOC" ] && MIGRATION_DOC=$(find "$REPO_ROOT/docs" -maxdepth 2 \( \
  -iname "migration*" -o -iname "upgrade*" -o -iname "breaking*" -o -iname "api-changes*" \
\) 2>/dev/null | head -1)  # timeout: 5000
```

Skip entirely when `$MIGRATION_DOC` empty — no migration/upgrade docs exist in project.

**When found**: for every classified item in ⚠ Breaking Changes, 🗑️ Deprecated, and ✗ Removed — verify present and described in `$MIGRATION_DOC`. "Present" = migration doc contains symbol name or semantically equivalent reference with upgrade instructions.

Check each item:

```bash
grep -i "<symbol_or_key>" "$MIGRATION_DOC" 2>/dev/null  # timeout: 3000
```

**Outcomes**:

- Found with upgrade path → `✓ <symbol> covered`
- Found but no upgrade path → `[SHALLOW] <symbol> in <doc> — present but missing upgrade instructions`
- Not found → `[MISSING-MIGRATION] <symbol> — ⚠ Breaking/🗑️ Deprecated but absent from <doc>`

Collect all findings as `migration_gaps` list. Zero findings → migration doc complete. Report before proceeding.

**Do not block** on `[SHALLOW]` findings — flag and continue. `[MISSING-MIGRATION]` findings surface as warnings; Draft migration guide phase must fill the gaps.

## Audit changelog

**`prepare`/`audit` modes**: delegated in parallel (see Delegation strategy). Reload paths:

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
IFS= read -r CHANGELOG_AUDIT_FILE < "${TMPDIR:-/tmp}/release-changelog-audit-${CSID}" 2>/dev/null || CHANGELOG_AUDIT_FILE=""
IFS= read -r CHANGELOG_FILE < "${TMPDIR:-/tmp}/release-changelog-file-${CSID}" 2>/dev/null || CHANGELOG_FILE=""
```

Read `$CHANGELOG_AUDIT_FILE` for audit findings; report added/flagged/unsourced counts from delegation envelope. If file missing (delegation skipped), fall back to inline below.

**`notes` mode or delegation fallback**:

Search order: `CHANGELOG.md` at repo root, `docs/CHANGELOG.md`, any `CHANGELOG*` one level deep (excluding `node_modules/`, `.venv/`, `vendor/`). Store as `$CHANGELOG_FILE`.

Before the first changelog edit in every `notes` run, stage the complete artifact set, including plain notes and the invalid-marker append fallback. If no changelog exists, select `CHANGELOG.md` as its destination. This begin step copies the live draft, changelog, selected dated plain summary/migration outputs, existing standalone summary/migration files, provenance, and marker; it does not publish any bytes. A pending promotion is recovered during setup before a new candidate begins. Keep `$CHANGELOG_FILE` as the live path for reporting and provenance, and use `$EDIT_CHANGELOG_FILE` for every changelog Read/Edit/Write from this point through the final truth gate:

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
IFS= read -r BRANCH < "${TMPDIR:-/tmp}/release-setup-${CSID}/BRANCH" 2>/dev/null || BRANCH=""
IFS= read -r LAST_TAG < "${TMPDIR:-/tmp}/release-setup-${CSID}/LAST_TAG" 2>/dev/null || LAST_TAG=""
IFS= read -r DO_APPEND < "${TMPDIR:-/tmp}/release-do-append-${CSID}" 2>/dev/null || DO_APPEND="false"
IFS= read -r RELEASE_MODE < "${TMPDIR:-/tmp}/release-mode-${CSID}" 2>/dev/null || RELEASE_MODE=""
IFS= read -r DO_SUMMARY < "${TMPDIR:-/tmp}/release-do-summary-${CSID}" 2>/dev/null || DO_SUMMARY="false"
IFS= read -r DO_MIGRATION < "${TMPDIR:-/tmp}/release-do-migration-${CSID}" 2>/dev/null || DO_MIGRATION="false"
IFS= read -r BRANCH_REF < "${TMPDIR:-/tmp}/release-setup-${CSID}/BRANCH_REF" 2>/dev/null || BRANCH_REF=""
IFS= read -r BRANCH_KEY < "${TMPDIR:-/tmp}/release-setup-${CSID}/BRANCH_KEY" 2>/dev/null || BRANCH_KEY=""
IFS= read -r DATE < "${TMPDIR:-/tmp}/release-setup-${CSID}/DATE" 2>/dev/null || DATE=""
IFS= read -r FROZEN_HEAD < "${TMPDIR:-/tmp}/release-append-head-${CSID}" 2>/dev/null || FROZEN_HEAD=""
IFS= read -r FROZEN_START < "${TMPDIR:-/tmp}/release-append-start-${CSID}" 2>/dev/null || FROZEN_START=""
IFS= read -r FROZEN_END < "${TMPDIR:-/tmp}/release-append-end-${CSID}" 2>/dev/null || FROZEN_END=""
FROZEN_END="${FROZEN_END:-$FROZEN_HEAD}"
IFS= read -r FROZEN_BRANCH < "${TMPDIR:-/tmp}/release-append-branch-${CSID}" 2>/dev/null || FROZEN_BRANCH=""
FROZEN_TAGS_FILE="${TMPDIR:-/tmp}/release-append-tags-${CSID}"
CHANGELOG_FILE="${CHANGELOG_FILE:-CHANGELOG.md}"
EDIT_CHANGELOG_FILE="$CHANGELOG_FILE"
: > "${TMPDIR:-/tmp}/release-append-stage-${CSID}"
if [ "$RELEASE_MODE" = notes ]; then
    [ -n "$FROZEN_HEAD" ] && [ -n "$FROZEN_BRANCH" ] && [ -f "$FROZEN_TAGS_FILE" ] || { echo "Error: frozen append source missing" >&2; exit 1; }
    python "${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}/bin/release_append_publish.py" check --branch "$BRANCH" --head-sha "$FROZEN_HEAD" --branch-ref "$FROZEN_BRANCH" --tag-state-file "$FROZEN_TAGS_FILE" || exit 1
fi
if [ "$RELEASE_MODE" = notes ]; then
    [ -n "$FROZEN_START" ] || { echo "Error: frozen append interval missing" >&2; exit 1; }
    START_ARG=()
    if [ "$(python "${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}/bin/release_append_marker.py" is-valid --branch "$FROZEN_BRANCH" --last-tag "$LAST_TAG")" = true ]; then
        IFS= read -r SAVED_MARKER < ".temp/release-state-v2/$BRANCH_KEY/marker" || exit 1
        START_ARG=(--start-sha "$SAVED_MARKER")
    fi
    EXTRA_ARGS=()
    if [ "$DO_APPEND" != true ]; then
        [ "$DO_SUMMARY" != true ] || EXTRA_ARGS+=(--output ".temp/output-release-summary-$BRANCH-$DATE.md")
        [ "$DO_MIGRATION" != true ] || EXTRA_ARGS+=(--output ".temp/output-release-migration-$BRANCH-$DATE.md")
    fi
    APPEND_STAGE=$(python "${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}/bin/release_append_publish.py" begin --branch "$BRANCH" --changelog "$CHANGELOG_FILE" --head-sha "$FROZEN_HEAD" --marker-sha "$FROZEN_END" --range-start "$FROZEN_START" --last-tag "$LAST_TAG" "${START_ARG[@]}" "${EXTRA_ARGS[@]}" --branch-ref "$FROZEN_BRANCH" --tag-state-file "$FROZEN_TAGS_FILE") || exit 1
    printf '%s\n' "$APPEND_STAGE" > "${TMPDIR:-/tmp}/release-append-stage-${CSID}"
    EDIT_CHANGELOG_FILE="$APPEND_STAGE/$CHANGELOG_FILE"
fi
```

Fresh shells must reload `$APPEND_STAGE` from `release-append-stage-${CSID}` and derive `$EDIT_CHANGELOG_FILE="$APPEND_STAGE/$CHANGELOG_FILE"` before any notes changelog Read/Edit/Write; a missing candidate is a hard stop. The live `$CHANGELOG_FILE` is read only for earlier cross-cycle detection and is not changed by this audit.

If `$EDIT_CHANGELOG_FILE` exists: cross-check its unreleased section. Items absent → add (same emoji format) to `$EDIT_CHANGELOG_FILE`. Items in CHANGELOG not matching classified → flag for review (no auto-delete). For each REVERT_SET pair: add `🔄 Reverted: <original change description> (introduced and reverted in this release)`. Preserve historical entries; remove only the exact matching Unreleased entry for an original change that has not shipped, when that stale claim is confirmed. Reverted items never in highlights or migration guide.

**Mandatory evidence link** (`modes/classify-truth-check.md` "Mandatory evidence link"): every entry added carries its source in the artifact's own form — CHANGELOG `([#N](url))` per `guidelines/writing-rules.md`, or `(sha7)` when no PR exists — never bare. No PR/sha available for a classified item → flag it instead of writing it unsourced. Existing Unreleased bullet with no PR number and no sha in any form (`(#N)`, `([#N](url))`, `(sha7)`) → flag `medium`, "unsourced entry" (no auto-backfill, no auto-delete).

For each `CROSS_CYCLE_MATCH` targeting `$CHANGELOG_FILE` (from Gather changes' cross-cycle detection — original predates `$RANGE`, matched text found in Unreleased from a prior `--append` cycle): strike/remove the matched entry the same way — do not add a redundant `🔄 Reverted` bullet for something the reader never saw shipped in this visible cycle.

If missing: create `$EDIT_CHANGELOG_FILE` (inside the candidate for notes, or the selected path for a delegation fallback); populate with `# Changelog` header and `## [Unreleased]` from Classify.

After resolving or creating `$CHANGELOG_FILE`, persist its exact path for fresh-shell append validation:

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
[ -n "$CHANGELOG_FILE" ] && [ -f "${EDIT_CHANGELOG_FILE:-$CHANGELOG_FILE}" ] || { echo "Error: changelog path unresolved" >&2; exit 1; }
printf '%s\n' "$CHANGELOG_FILE" > "${TMPDIR:-/tmp}/release-changelog-file-${CSID}"
```

**Scope check** (same rule as delegated Agent A — see `modes/changelog-audit-prompt.md`): `git log $RANGE --merges --pretty='%H %P %s'`, keep rows whose subject doesn't match `merge pull request #[0-9]+|\(#[0-9]+\)` — a raw branch merge landed inside `$RANGE`, not a real PR merge. Diff each such commit's two parents to list what it pulled in; any pulled-in commit already shipped in a prior CHANGELOG section is `critical` — it's about to appear a second time in the wrong release under this PR by accident. Report as its own line, never folded into the add/flag count silently.

Always report: "N items added, M flagged for review, K scope-flagged (non-PR branch merge), U unsourced (no PR/commit evidence)." This phase owns CHANGELOG-format classification; Write release draft reads from it — does NOT copy. DRAFT.md uses different format.

## Extract contributors

**`prepare`/`audit` modes**: delegated in parallel (see Delegation strategy). Reload path:

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
IFS= read -r CONTRIBUTORS_FILE < "${TMPDIR:-/tmp}/release-contributors-${CSID}" 2>/dev/null || CONTRIBUTORS_FILE=""
```

Read `$CONTRIBUTORS_FILE` for formatted contributors list. If file missing (delegation skipped), fall back to inline below.

**`notes` mode or delegation fallback**:

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
# reload $RANGE (Check 41)
IFS= read -r RANGE < "${TMPDIR:-/tmp}/release-range-${CSID}" 2>/dev/null || RANGE=""
python "${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}/bin/extract_contributors.py" --range "$RANGE" --include-bots  # timeout: 5000
```

`extract_contributors.py --include-bots` emits one `Name <email>` line per contributor, already deduplicated by email. Keep GitHub privacy-email humans; separate bot identities into the single automated-contributions line required by `guidelines/writing-rules.md`. Every commit counts, including docs and typo fixes.

Build the contributor inventory as the union of Git authors/coauthors and every PR author verified to belong to this release range. Use Gather changes' commit-to-PR associations, not the entire merged-PR list or merge dates alone. Include PR-only authors even when a maintainer authored the squash commit and no coauthor trailer exists. Preserve the PR-to-commit evidence in the gathered change table passed to the delegated path. If association or PR-author coverage is unavailable, flag incomplete contributor coverage; never claim completeness from Git alone.

Reconcile duplicates only through verified login/email linkage, including GitHub privacy-email handles; display-name similarity alone is not identity proof. Keep unmatched Git people and PR-only people; use the known login when a PR author's display name is unavailable, never invent an email or name. Deleted/unavailable PR identities remain an explicit coverage gap. Aggregate bot identities before individual formatting or profile lookup; never send bots through those human-only steps.

For each human contributor, inspect commits in range (`git log "$RANGE" --no-merges --author="<email>" --oneline`) when their email is known; for PR-only authors, use their verified in-range PR changes instead. Pick up to 3 most significant contributions. Rank: new public API > major UX improvement > significant fix > internal change > docs/typo. No PR numbers, no issue links, no `(#N)` references in public credit prose.

Resolve GitHub handles from the verified PR author data (`author.login`) or confirmed Git identity linkage. If unresolved, omit the handle.

For each resolved human handle, find a LinkedIn link via this ordered chain — stop at first hit, never guess/infer/search-by-name at any step. Contributor **name is never a matching key anywhere in this chain** — only the resolved GitHub handle (steps 1, 2, 4 below) or an anchor href read directly from fetched page content (step 3):

1. **Primary — Social Accounts API** (strongest signal: explicitly added by the person to their own GitHub profile):

   ```bash
   gh api "/users/<login>/social_accounts" --jq '.[] | select(.provider=="linkedin") | .url' 2>/dev/null  # timeout: 6000
   ```

   Non-empty output → use directly, done.

2. **Fallback A — `.blog` field**:

   ```bash
   gh api /users/<login> --jq '{blog: .blog, twitter: .twitter_username}' 2>/dev/null  # timeout: 6000
   ```

   `.blog` contains `linkedin.com` → use directly, done.

3. **Fallback B — personal-page exception**: `.blog` is a non-empty URL, NOT a `linkedin.com` URL → `WebFetch` that page, scan its actual returned content for `linkedin.com/in/...` anchor links (real hrefs read from the page, never inferred from surrounding text). Exactly one distinct such link found → use it, done. Zero or multiple distinct links found → do not guess; omit LinkedIn for this contributor.

4. **Fallback C — past releases**: search for a Contributors entry, keyed by this EXACT GitHub handle, that a prior release already resolved and credited with a `[LinkedIn](...)` link:

   ```bash
   grep -rn "@<login>" CHANGELOG.md docs/CHANGELOG.md releases/*/SUMMARY.md releases/*/DRAFT.md 2>/dev/null | grep -m1 '\[LinkedIn\]('  # timeout: 5000
   # only when the above finds nothing — scan published release bodies (stop at first match):
   for tag in $(gh release list --limit 100 --json tagName --jq '.[].tagName' 2>/dev/null); do  # timeout: 15000
       gh release view "$tag" --json body --jq '.body' 2>/dev/null | grep -m1 "@<login>.*\[LinkedIn\]("  # timeout: 6000
   done | head -1
   ```

   Found → reuse that URL verbatim, done. Not found → omit.

5. No step produced a link → omit LinkedIn, same as today's no-match behavior.

Canonical Contributors format (the template's Contributors line points here): `- **Name** (@github_handle, [LinkedIn](https://linkedin.com/in/handle)) — <brief what they did>`. Omit `@handle` when unresolvable. `<brief what they did>` picks up to 3 most significant contributions per line 440's ranking and describes the contribution itself (feature/fix/area) — never a PR number, issue link, or `(#N)` reference.

## Identify highlights

Pick top 3–5 most significant changes from Classify. Ranking: breaking changes > new public API > major UX improvements > notable fixes. Pull concrete code example from explore-codebase diff for each. Drives Summary pitch and Spotlights section.

## Draft migration guide

Always produce. No breaking changes → single line "No breaking changes in this release." Deprecations/removals → show before→after code examples. State in preamble: API deprecated in prior release and now removed → ✗ Removed (not Breaking).

If `migration_gaps` non-empty (from Validate migration docs): for each `[MISSING-MIGRATION]` item, add dedicated section covering that symbol with before→after example. For each `[SHALLOW]` item, expand existing coverage to add concrete upgrade instructions.

If `breaking_callers` non-empty (from Breaking-change classification): for each Breaking symbol, add a before→after section citing its external call sites (the `migration_lines`) so downstream consumers see exactly which of their call sites must change. When that phase reported `query_complete:false`, prefix the section "Affected call sites (possibly-incomplete — codemap coverage partial)".

## Generate release demo

**Only for feature releases** (≥1 🚀 Added items). Skip bug-fix-only releases.

Self-contained Python script in jupytext percent (`# %%`) format. Full story: install → setup → demonstrate each highlight → verify output.

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
# reload Shared-setup vars (Check 41)
IFS= read -r BRANCH < "${TMPDIR:-/tmp}/release-setup-${CSID}/BRANCH" 2>/dev/null || BRANCH=""
IFS= read -r DATE < "${TMPDIR:-/tmp}/release-setup-${CSID}/DATE" 2>/dev/null || DATE=""
DEMO_OUT=".temp/release-demo-$BRANCH-$DATE.py"
mkdir -p .temp  # timeout: 5000
echo "${DEMO_OUT:-}" > "${TMPDIR:-/tmp}/release-demo-out-${CSID}"
```

Write demo to `$DEMO_OUT`. (`prepare` mode: `releases/$VERSION/demo.py` — see Phase 4.)

**Gate: demo must execute to completion before proceeding to Draft executive summary.**

<!-- branch: demo-approval — only in demo/prepare mode; isolated from notes/changelog path; ≤1 call -->

Invoke `AskUserQuestion` — "Ready to run demo script `$DEMO_OUT`?" Options: (a) Run now · (b) Review first · (c) Skip and **exclude from release artifacts**.

On option (c): mark demo excluded, skip to Draft executive summary — do NOT invoke the failure-path AskUserQuestion below.

On (a) or (b) confirmed:

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
IFS= read -r DEMO_OUT < "${TMPDIR:-/tmp}/release-demo-out-${CSID}" 2>/dev/null || DEMO_OUT=""
python "$DEMO_OUT"  # timeout: 600000
DEMO_EXIT=$?
echo "${DEMO_EXIT}" > "${TMPDIR:-/tmp}/release-demo-exit-${CSID}"
```

<!-- policy-sibling: plugins/cc_oss/skills/release/modes/prepare.md §Phase 4a execution gate (demo retry bound) -->

**Guard**: only proceed to failure handling when `$DEMO_EXIT -ne 0` after attempting a fix. Success (`$DEMO_EXIT = 0`) → proceed directly to Draft executive summary — no AskUserQuestion. Failure → fix and re-run (max 3 iterations total). Only after 3 failed attempts invoke `AskUserQuestion` ("Demo still failing after 3 attempts. Exclude from release and continue, or abort?"). Self-contained: package installed in current env; no live API calls or network deps; deterministic synthetic data; `# !pip install` lines are Python comments — interpreter skips.

## Draft executive summary

1–2 paragraph executive summary: what release is, why it matters, who benefits. Based on Identify highlights. Save `.temp/output-release-summary-$BRANCH-$DATE.md`. This text stays internal (standalone `SUMMARY.md`); it is only the fact source for DRAFT.md's `## 📋 Summary`, which Write release draft re-renders into the pitch shape in `templates/release-draft.md` — never paste these paragraphs there.

## Write release draft

Pre-flight — verify all templates present before proceeding:

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
# reload Shared-setup vars (Check 41)
IFS= read -r SKILL_DIR < "${TMPDIR:-/tmp}/release-setup-${CSID}/SKILL_DIR" 2>/dev/null || SKILL_DIR=""
IFS= read -r BRANCH < "${TMPDIR:-/tmp}/release-setup-${CSID}/BRANCH" 2>/dev/null || BRANCH=""
IFS= read -r DATE < "${TMPDIR:-/tmp}/release-setup-${CSID}/DATE" 2>/dev/null || DATE=""
IFS= read -r DO_APPEND < "${TMPDIR:-/tmp}/release-do-append-${CSID}" 2>/dev/null || DO_APPEND="false"
[ -z "$SKILL_DIR" ] && echo "Error: could not locate release skill directory" && exit 1
for tmpl in release-draft.md audit-checks.md gather-prompt.md; do # timeout: 5000
    [ -f "$SKILL_DIR/templates/$tmpl" ] || {
        echo "Missing template: $tmpl — aborting"
        exit 1
    }
done
```

Before writing, fetch last 2–3 releases to check formatting conventions:

```bash
gh release list --limit 5                                                  # timeout: 30000
LATEST_TAG=$(gh release list --limit 100 --json tagName --jq '[.[] | select(.tagName | test("rc|dev|alpha|beta"; "i") | not)] | .[0].tagName // empty') # timeout: 30000
[ -z "$LATEST_TAG" ] || [ "$LATEST_TAG" = "null" ] && echo "No releases found — using template defaults" || gh release view "$LATEST_TAG"  # timeout: 15000
```

Existing releases deviate from templates → match tone and prose style only. **Never** use `# Changelog` structure for DRAFT.md — always use `release-draft.md` structure. `gh release list` empty → use template defaults.

Fetch origin URL for full changelog link:

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
ORIGIN_URL=$(git remote get-url origin 2>/dev/null || echo "")  # timeout: 3000
# reload SKILL_DIR (Check 41)
IFS= read -r SKILL_DIR < "${TMPDIR:-/tmp}/release-setup-${CSID}/SKILL_DIR" 2>/dev/null || SKILL_DIR=""
cat "$SKILL_DIR/templates/release-draft.md"  # timeout: 5000
```

**DRAFT.md format guard**: must NOT start with `# Changelog`, must NOT use CHANGELOG section structure. CHANGELOG-format classification = internal working doc only — derive sections from it, don't copy verbatim.

Template (loaded above). Fill `[Full changelog URL]` as `<https form of $ORIGIN_URL>/compare/<LAST_TAG>...<new tag>` (no previous tag → link the release tag's commit history). Omit empty sections.

Key difference from `prepare`: phases run inline (no subagent delegation); output to `DRAFT.md` and root `CHANGELOG.md`.

### Adversarial review

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
# reload SKILL_DIR (Check 41)
IFS= read -r SKILL_DIR < "${TMPDIR:-/tmp}/release-setup-${CSID}/SKILL_DIR" 2>/dev/null || SKILL_DIR=""
cat "$SKILL_DIR/modes/adversarial-review.md"  # timeout: 5000
```

Follow above and execute.

<!-- loads: modes/release-draft-template.md -->

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
# reload SKILL_DIR (Check 41)
IFS= read -r SKILL_DIR < "${TMPDIR:-/tmp}/release-setup-${CSID}/SKILL_DIR" 2>/dev/null || SKILL_DIR=""
cat "$SKILL_DIR/modes/release-draft-template.md"  # timeout: 5000
```

Follow above and execute (format templates, semantic consistency review, polish, shepherd spawn, write to disk).

## Mode: prepare

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
# reload SKILL_DIR (Check 41)
IFS= read -r SKILL_DIR < "${TMPDIR:-/tmp}/release-setup-${CSID}/SKILL_DIR" 2>/dev/null || SKILL_DIR=""
[ -f "$SKILL_DIR/modes/prepare.md" ] || { echo "Error: modes/prepare.md not found at $SKILL_DIR/modes/prepare.md — verify oss plugin installation"; exit 1; }
cat "$SKILL_DIR/modes/prepare.md"  # timeout: 5000
```

Follow above and execute.

> Confidence block — prepare mode: end response with `## Confidence` block per CLAUDE.md output standards after prepare.md completes.

## Mode: audit

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
# reload SKILL_DIR (Check 41)
IFS= read -r SKILL_DIR < "${TMPDIR:-/tmp}/release-setup-${CSID}/SKILL_DIR" 2>/dev/null || SKILL_DIR=""
[ -f "$SKILL_DIR/modes/audit.md" ] || { echo "Error: modes/audit.md not found at $SKILL_DIR/modes/audit.md — verify oss plugin installation"; exit 1; }
# refuse to audit already-published release
IFS= read -r _REST_LINE < "${TMPDIR:-/tmp}/release-rest-${CSID}" 2>/dev/null || _REST_LINE=""; _AUDIT_VERSION=${_REST_LINE%% *}
if [ -n "$_AUDIT_VERSION" ]; then
    if gh release view "$_AUDIT_VERSION" --json tagName --jq .tagName >/dev/null 2>&1; then  # timeout: 15000
        echo "! BLOCKED — $_AUDIT_VERSION is already a published release on GitHub. /release audit checks FORWARD readiness only."
        echo "  For retrospective analysis use: /oss:analyse  (requires oss plugin)"
        exit 1
    fi
fi
cat "$SKILL_DIR/modes/audit.md"  # timeout: 5000
```

Follow above and execute.

> Confidence block — audit mode: end response with `## Confidence` block per CLAUDE.md output standards after audit.md completes.

## Mode: demo

```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
# reload SKILL_DIR (Check 41)
IFS= read -r SKILL_DIR < "${TMPDIR:-/tmp}/release-setup-${CSID}/SKILL_DIR" 2>/dev/null || SKILL_DIR=""
[ -f "$SKILL_DIR/modes/demo.md" ] || { echo "Error: modes/demo.md not found at $SKILL_DIR/modes/demo.md — verify oss plugin installation"; exit 1; }
cat "$SKILL_DIR/modes/demo.md"  # timeout: 5000
```

Follow above and execute.

> Confidence block — demo mode: end response with `## Confidence` block per CLAUDE.md output standards after demo.md completes.

</workflow>

<notes>

- **Doc artifacts ≠ released product**: CHANGELOG.md, DRAFT.md, MIGRATION.md, SUMMARY.md, demo.py = communication artifacts; released product published separately via `git tag`, `gh release create`, PyPI upload.
- **AskUserQuestion usage**: spread across `notes`/`prepare`/`audit`/`demo` modes; each call in distinct branch-path (no single path has >4 sequential calls); compliant with sequential-call limit.
- **Numbers reference**: numeric limits documented with rationale in `guidelines/numbers-reference.md`; update whenever limits change
- Filter noise (CI config, dep bumps, typos) unless user-impacting
- **Public-facing content policy**: user-visible changes only. Never include: internal staff names, internal maintenance, CI/tooling, internal dep bumps, housekeeping with no user impact.
- **Contributor email privacy**: `.temp/` must be in `.gitignore` — emails from `git log --format="%aN <%aE>"` must not leak into repo.
- Public-facing output co-authored with `oss:shepherd` (requires `oss` plugin) — follow shepherd voice protocol:
  ```bash
  export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
  IFS= read -r _OSS_SHARED < "${TMPDIR:-/tmp}/release-oss-shared-${CSID}" 2>/dev/null || _OSS_SHARED=""
  cat "$_OSS_SHARED/shepherd-voice.md"  # timeout: 5000
  ```
- **Demo mode output**: jupytext percent format — convert with `jupytext --to notebook <file>.py`; replace placeholder URLs before publishing; Colab badge URL must point to actual notebook after upload

<!-- branch: demo-synthetic-fallback — only when real data unavailable; isolated deep in demo path -->

- **Demo real-world-only policy**: use actual project data/fixtures/API — synthetic requires explicit user approval; fallback: (1) document each failed attempt in `## Demo attempts`, (2) ask Codex if available, (3) ask user via `AskUserQuestion`, (4) synthetic only on explicit approval
- **Changelog audit preserves history**: add missing entries and flag unrelated or uncertain extras. Remove only the exact confirmed stale Unreleased claim for an unshipped revert/pivot; preserve historical entries and every unrelated item.
- **`--append` marker**: `.temp/release-state-v2/<branch-key>/marker` — not date-stamped like sibling `.temp/release-*` artifacts (must survive across days/sessions); losing it (TTL cleanup, gitignore) degrades safely to the existing full-range/full-overwrite behavior, never to corruption — see `bin/release_append_marker.py` docstring for the full rationale.
- **Provenance store (patch-id keyed)**: `.temp/release-state-v2/<branch-key>/provenance.jsonl` — same cross-session lifecycle reasoning as the marker (per-branch, `.temp/`, gitignored, not date-stamped). Append-only log, one `{schema_version: 1, patch_id, sha, subject, artifact, anchor_text, written_at}` record per line, one per (contributing commit, artifact, exact-written-text) tuple; every `notes`-mode write (full regenerate or `--append` merge) stages the records for each newly-written bullet/entry that traces to specific commit(s) in a `.rec` file and appends them with `bin/append_ledger.py`, never rewriting earlier lines — see "Post-write bookkeeping" → "Provenance record" in `release-draft-template.md`.
  - Historical `provenance.json` (unversioned JSON array, rewritten whole each cycle) in the same directory is read-only data: the revert lookup still reads it, but no step stages, appends to, or publishes it.
  - Consumed by Gather changes' cross-cycle revert detection: a genuine `git revert` commit's own `This reverts commit <sha>.` trailer is converted to its `git patch-id --stable` and looked up here — a hit means an exact, deterministic `{artifact, anchor_text}` strike, no text-matching guesswork.
  - **`patch_id` is the only lookup key**: a raw commit sha changes on `--amend`, `rebase`, or `cherry-pick` even when the diff is untouched, so a sha-keyed store would silently miss a revert of a commit reworded or cherry-picked since it was recorded; `git patch-id --stable` is a normalized hash of the diff content and survives all three (verified empirically: identical patch-id across `--amend` and cherry-pick onto another branch, while the sha changed each time). `sha` and `subject` are carried for human debugging only, never used as a matching key.
  - A commit whose diff produces no stable patch-id (a merge commit shown without `-m`, or a genuinely empty commit) is recorded with `patch_id: null` and can only ever be struck via the semantic path — a documented gap, not a bug.
  - Recorded artifacts in practice: DRAFT.md (Notable-changes, Spotlights, Migration guide), `$CHANGELOG_FILE`, standalone `MIGRATION.md` — never Summary (DRAFT.md's own section or standalone `SUMMARY.md`, blended prose without a reliable commit anchor) or Contributors (per-person, not per-commit-revertible).
  - Routine appends preserve earlier SUMMARY.md prose and carried DRAFT.md win bullets; source-backed corrections in the final cross-artifact truth gate remain required and do not use patch-id provenance.
  - Losing the store (TTL cleanup) degrades the revert path to the same semantic-grep fallback already used for pivots — never to corruption or a silently-missed strike (Semantic consistency review is still the backstop).
- **`--append` integration scope**: covers every DRAFT.md section (Summary, Spotlights, Migration guide, Notable-changes subsections, Contributors), plus root-level `SUMMARY.md`/`MIGRATION.md` when their flags are set. Valid-marker runs merge against staged copies; invalid-marker runs full-regenerate staged copies. Both publish through `bin/release_append_publish.py` after source and truth checks; its journal finishes interrupted promotion without replaying Summary prose and refuses unknown user edits. Merge is purely additive except DRAFT.md's re-folded Summary and a detected cross-cycle revert/pivot (Gather changes' `CROSS_CYCLE_MATCH`), which strikes the specific stale entry instead of leaving a contradicting pair.
- **Post-merge re-validation gates only the merge path**: Truth check / Identify highlights / Validate migration docs / Validate docs re-run against the final merged DRAFT.md only when `$MARKER_VALID == true` (see release-draft-template.md). A full regenerate (no marker, or `prepare`) is already single-pass-valid — nothing accumulated from a prior cycle to re-check.
- **Collapse guard**: `SUMMARY.md`/`MIGRATION.md` merges (whole-file artifacts, no section structure to sanity-check against) carry a mechanical byte-count trip-wire — content collapsing from substantial to near-empty during a merge cycle is refused and restored from the pre-merge Read, never silently written (see `release-draft-template.md` "Collapse guard"). DRAFT.md's own sections can legitimately empty down to a dropped header (all items struck, nothing added) — that's intended, not guarded against; the guard is scoped to the two headerless artifacts where a full-file wipe was the actual historical bug.
- **Every completed `notes`-mode write refreshes the marker** — including plain (non-`--append`) runs. Marker refresh uses the completed range endpoint after the final truth gate; an explicit range ending before `HEAD` never certifies later commits. The workflow then records a receipt bound to branch, source `HEAD`, range endpoint, and exact nonempty draft bytes, and `write` refuses missing or stale receipts. This is a workflow gate against accidental direct writes, not an attestation against a local caller who forges workflow inputs. New side effect for users who've never used `--append`: a `.temp/release-state-v2/<branch-key>/marker` file now appears. It is gitignored and seeds the baseline for later `--append` adoption without changing `DRAFT.md`/`CHANGELOG.md` output.
- **DRAFT.md Summary folds; standalone `SUMMARY.md` accumulates**: each `--append` cycle re-ranks DRAFT.md's win bullets over the whole release (max 5) and replaces its one `New since last draft` pointer line with only that cycle's win names, so the public Summary stays readable at a glance. Internal `SUMMARY.md` still piles increment paragraphs under its own `### Since last draft` with no `remove` path — cosmetic drift, not a correctness or data-loss issue; the next full regenerate (no marker / `prepare`) reconciles it.
- Follow-up chains:
  - Readiness check → `/oss:release prepare <version>` runs built-in audit first; use standalone `/oss:release audit [version]` only for readiness check without cutting release
  - Breaking changes → `/oss:analyse` (requires `oss` plugin) for ecosystem impact
  - Notes/changelog written → `gh release create` must be user-run via project tooling
  - `migration` content written → add to project docs, link from CHANGELOG entry

</notes>

<calibration>

Registered: `notes` mode — classification accuracy (change type, section assignment, noise filtering).

Future candidates (not yet registered): `prepare` (pipeline completeness), `audit` (verdict accuracy: READY/NEEDS_ATTENTION/BLOCKED), `demo` (headline feature selection, narrative quality, code cell correctness).

</calibration>

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.