release · diff

git:20261021.7b6ff28 to git:20261021.076157a

1 added, 0 removed. Audit A to A.

---
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, Skill, 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
`prepare` and `audit` delegate gather/explore/validate (pre-compute, envelope validation, ledger reconcile, Phases 5–6 parallel delegation); `notes` and `demo` delegate only through the Size guard below. Load the procedure before the first delegated step — an inline `notes`/`demo` run under the size guard never needs it:
```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
IFS= read -r RELEASE_MODE < "${TMPDIR:-/tmp}/release-mode-${CSID}" 2>/dev/null || RELEASE_MODE=""
IFS= read -r RANGE < "${TMPDIR:-/tmp}/release-range-${CSID}" 2>/dev/null || RANGE=""
IFS= read -r LAST_TAG < "${TMPDIR:-/tmp}/release-setup-${CSID}/LAST_TAG" 2>/dev/null || LAST_TAG=""
case "$RELEASE_MODE" in notes|demo) [ "$(git rev-list --count "${RANGE:-${LAST_TAG:-HEAD~20}..HEAD}" 2>/dev/null)" -gt 50 ] 2>/dev/null || exit 0 ;; esac # size guard: ≤50 commits runs inline, procedure never loaded
# 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/delegation.md" # timeout: 5000
```
<!-- loads: modes/delegation.md -->
Follow above and execute. The breaking-waiver gate and unconfirmed notification below apply in every mode: delegated runs after the ledger reconcile in `modes/delegation.md`, inline runs after Truth check (below).
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 ledger reconcile in `modes/delegation.md`; a count alone is not item evidence.
- Invoke `AskUserQuestion` with one question per item, at most 4 per call (more items → successive calls of ≤4): **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) in the run's final reply — the Bash output below is not reply text the user reads, and text written before a tool call can arrive as an empty progress update. 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=""
IFS= read -r UNCONFIRMED < "${TMPDIR:-/tmp}/release-unconfirmed-${CSID}" 2>/dev/null || UNCONFIRMED=0
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.
`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 the envelope-validation block in `modes/delegation.md` 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
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}"
# --keep-state: no eval, writes release-{changelog,summary,migration,append,clean-args}-${CSID}; --inlined: release keeps no compaction contract, so it clears none
python "${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}/bin/parse-skill-flags.py" --flags changelog,summary,migration,append --keep-state release --inlined "$ARGUMENTS" >/dev/null # timeout: 5000
IFS= read -r DO_CHANGELOG < "${TMPDIR:-/tmp}/release-changelog-${CSID}" 2>/dev/null || DO_CHANGELOG=false
IFS= read -r DO_SUMMARY < "${TMPDIR:-/tmp}/release-summary-${CSID}" 2>/dev/null || DO_SUMMARY=false
IFS= read -r DO_MIGRATION < "${TMPDIR:-/tmp}/release-migration-${CSID}" 2>/dev/null || DO_MIGRATION=false
IFS= read -r DO_APPEND < "${TMPDIR:-/tmp}/release-append-${CSID}" 2>/dev/null || DO_APPEND=false
IFS= read -r CLEAN_ARGS < "${TMPDIR:-/tmp}/release-clean-args-${CSID}" 2>/dev/null || CLEAN_ARGS=""
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_CHANGELOG}" > "${TMPDIR:-/tmp}/release-do-changelog-${CSID}"
echo "${DO_SUMMARY}" > "${TMPDIR:-/tmp}/release-do-summary-${CSID}"
echo "${DO_MIGRATION}" > "${TMPDIR:-/tmp}/release-do-migration-${CSID}"
# explicit range dies with this shell; only v1..v2 persists (lone token = prepare/audit version, not a range); always written so a prior run's range never leaks in
case "$RANGE" in *..*) printf '%s\n' "$RANGE";; *) printf '\n';; esac > "${TMPDIR:-/tmp}/release-range-${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` naming those flags in its question text (tool output is not reply text the user reads) — (a) **Abort** (stop, re-invoke) · (b) **Continue ignoring**. On Abort: stop.
## Shared setup
Run this first. 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. One block first runs the cold-start fallback (sets `$_OSS_SHARED`) and loads `agent-resolution.md`, then the setup script:
```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
[ -d "$_OSS_SHARED" ] && cat "$_OSS_SHARED/agent-resolution.md" # timeout: 5000
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
```
**Agent Resolution** — `agent-resolution.md` (loaded above): foundry check + fallback table. Foundry not installed → at every spawn substitute `foundry:X` with `general-purpose` + the table's role prefix and model. Agents this skill dispatches: `foundry:sw-engineer` (gather, size-guard gather, adversarial review), `foundry:doc-scribe` (changelog audit, contributors). The `foundry:humanizer` pass in `modes/release-draft-template.md` keeps its own availability rule. <!-- Inline fallback (if agent-resolution.md unreadable): foundry:sw-engineer → general-purpose, foundry:doc-scribe → general-purpose. -->
<!-- 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=""
# explicit v1->v2 from Mode detection; empty → marker/LAST_TAG default below
[ -n "$RANGE" ] || IFS= read -r RANGE < "${TMPDIR:-/tmp}/release-range-${CSID}" 2>/dev/null || RANGE=""
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.
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
[ -n "$RANGE" ] || IFS= read -r RANGE < "${TMPDIR:-/tmp}/release-range-${CSID}" 2>/dev/null || RANGE=""
[ -n "$RANGE" ] || { echo "Error: release range unresolved; cannot enumerate candidate commits" >&2; exit 1; }
python "${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}/bin/gh_batch_query.py" commit-prs --range "$RANGE" --offset-file "${TMPDIR:-/tmp}/release-assoc-offset-${CSID}" || { echo "Error: PR association coverage unavailable; retain gap, do not claim complete credits" >&2; exit 1; } # timeout: 180000
```
One batched GraphQL request covers 25 commits (500 per run, about 20 requests). stderr printing `NEXT_OFFSET=<n>` → re-run this exact block until it stops printing it; association coverage is incomplete until then. Each output line is one commit's record `{commit, pull_requests: [{number, title, author, base, merge_commit_sha}]}`, merged PRs only; a `coverage_gap` field (commit unknown to GitHub, or more than 100 associated PRs) is an explicit coverage gap, never an empty association.
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). Runs in the orchestrator after Detect revert pairs. Load the procedure and the Append notes only for an `--append` run:
```bash
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
IFS= read -r DO_APPEND < "${TMPDIR:-/tmp}/release-do-append-${CSID}" 2>/dev/null || DO_APPEND="false"
[ "$DO_APPEND" = "true" ] || exit 0
# 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/append-cross-cycle.md" # timeout: 5000
```
<!-- loads: modes/append-cross-cycle.md -->
Follow above and execute. 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
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
IFS= read -r RANGE < "${TMPDIR:-/tmp}/release-range-${CSID}" 2>/dev/null || RANGE=""
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
printf '%s\n' "$MIGRATION_DOC" > "${TMPDIR:-/tmp}/release-migration-doc-${CSID}"
```
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
export CSID="${CLAUDE_CODE_SESSION_ID:-$PPID}"
IFS= read -r MIGRATION_DOC < "${TMPDIR:-/tmp}/release-migration-doc-${CSID}" 2>/dev/null || MIGRATION_DOC=""
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 them in the run's final reply, not before the next tool call (text written before a tool call can arrive as an empty progress update).
**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**:
`bin/resolve_changelog_file.py` (called in the fence below) resolves `$CHANGELOG_FILE`: `CHANGELOG.md` at repo root, `docs/CHANGELOG.md`, any `CHANGELOG*` one level deep (excluding `node_modules/`, `.venv/`, `vendor/`); none found → `CHANGELOG.md`. `prepare` and `audit` use the same resolver.
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}"
IFS= read -r REPO_ROOT < "${TMPDIR:-/tmp}/release-setup-${CSID}/REPO_ROOT" 2>/dev/null || REPO_ROOT=""
[ -n "${CHANGELOG_FILE:-}" ] || CHANGELOG_FILE=$(python "${CLAUDE_PLUGIN_ROOT:-plugins/cc_oss}/bin/resolve_changelog_file.py" --repo-root "${REPO_ROOT:-.}") || { echo "Error: changelog path unresolved" >&2; exit 1; } # timeout: 5000
printf '%s\n' "$CHANGELOG_FILE" > "${TMPDIR:-/tmp}/release-changelog-file-${CSID}"
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" ] || IFS= read -r CHANGELOG_FILE < "${TMPDIR:-/tmp}/release-changelog-file-${CSID}" 2>/dev/null || CHANGELOG_FILE=""
[ -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 the Extract contributors 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.
- **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` notes** (marker location, patch-id provenance store, integration scope, post-merge re-validation gate, collapse guard, Summary folding): `modes/append-cross-cycle.md` § Append notes, loaded with the cross-cycle procedure for an `--append` run.
- **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.
+ - **Confidence scores classification accuracy, not artifact completeness** (`notes` and `prepare` classification output; `audit` and `demo` keep the generic quality-gates Confidence rule): base the score on the evidence classification used — commit bodies, `BREAKING CHANGE:` footers, revert trailers, PR descriptions. Skipped Truth check / baseline / codemap verification and unresolved URLs or handles are listed as `(-0.00)` Gaps only when no specific classification depends on them. Deduct for a skipped Truth check with any ✗ Removed / ⚠ Breaking Changes item (the workflow's ❌ Removed category is included), or when a specific classification hinges on other repo state that went unverified: prior deprecation (✗ Removed vs ⚠ Breaking Changes), a revert whose original may predate `$RANGE`, or a Truth-check removal or reclassification.
- 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>