SLOPSHOPPER

Release Notes

Drafts App Store release notes for Xcode projects when the version is bumped, into fastlane metadata or CHANGELOG.md, and shows the version above the prompt.

bandcommandtoastmodelprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · release-notes
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /draft-release-notes ⎿ release-notes: No Xcode project with a MARKETING_VERSION in this repository. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

🧩 Claude Code Mods

Live panes, prompt bands, status line entries, toasts, and hooks for Claude Code, each shipped as a plugin.

Release CI License: MIT

🚀 Install

/plugin marketplace add tartinerlabs/claude-code-mods
/plugin install <mod> --marketplace tartinerlabs/claude-code-mods

🧰 Mods

| | Mod | Where it shows | In one line | | :-: | --- | --- | --- | | ⏸ | auto-resume | Status line, toast | Picks a turn back up once a usage limit resets | | 🏷 | auto-session-name | Session title | Names each session from its first prompt and keeps the name current as the work moves on | | ⏱ | cache-clock | Band above the prompt | Counts down to the prompt cache going cold | | 📊 | limit-watch | Band above the prompt, toasts | Shows the 5-hour and weekly usage limits and the session's cost | | 🛫 | pr-pilot | Band above the prompt, toasts | Shows the branch's PR checks and merges it with /merge | | 📝 | release-notes | Band above the prompt, toast | Drafts App Store release notes for Xcode projects when the version is bumped | | 📱 | resource-watch | Band above the prompt, toast | Shows which session is using each simulator, Claude Design project or the Xcode app, and messages the other session on a clash | | 🗜 | smart-compact | Toast | Compacts between turns, sooner at checkpoints or when the cache is cooling | | ⛅ | token-weather | Band above the prompt | Forecasts how full the context window is |


⏸ auto-resume

When a turn stops on a usage limit, waits until the limit resets and sends continue. Typing a prompt yourself or running /auto-resume cancel stops the wait.

⏸ auto-resume in 1h 12m                                    ← status line
Usage limit reset, resuming                                 ← toast

<sub>📁 plugins/auto-resume</sub>

🏷 auto-session-name

Names each session from its first prompt using Haiku and can give it a prompt bar colour, so it is easy to find with claude -r after a crash. When the first prompt is short, such as continue or look at this, it titles the session again from the last four prompts on the fourth. Every eighth prompt it checks the title against the last four prompts and renames the session only when the work has moved to a different task; small shifts keep the title. Sessions already named with -n or /rename are left alone, and a rename stops any re-title. Set the color option to red, blue, green, yellow, purple, orange, pink or cyan to colour named sessions; default leaves the colour alone.

> fix the flaky retry test in the upload worker
                                    → session titled "Fix flaky upload retry test"

<sub>📁 plugins/auto-session-name</sub>

⏱ cache-clock

Draws a countdown above the prompt to when the prompt cache goes cold, with the last request's hit rate. Once cold, it shows how much the next turn re-writes. Set the ttl option to 1h when extended caching is on.

🔥 cache 3:42 left · 96% hit                                ← green, yellow under a minute
🧊 cache cold · next turn re-writes 84.3k                   ← red

<sub>📁 plugins/cache-clock</sub>

📊 limit-watch

Draws the 5-hour and weekly usage limits above the prompt with live reset countdowns, green under 60%, yellow to 80% and red past it. Toasts once a window passes 80% and again at 95%. Any other window the API reports, such as a gateway spend limit, shows under its own name. The session's cost so far follows the limits.

🕔 5h 42% · 2h17m  📅 7d 81% · 3d4h  💰 $2.36              ← band
7d limit at 81%, resets in 3d4h                             ← toast

<sub>📁 plugins/limit-watch</sub>

🛫 pr-pilot

Draws the current branch's open pull request above the prompt with its checks and whether it can merge, refreshed each minute and after every turn. Toasts when a run of checks finishes. /merge merges it with gh and deletes the branch, but only once every check has passed and nothing blocks it. On a gh-stack layer the band shows its place in the stack, and /merge merges the whole stack with gh stack merge once every unmerged layer has passed its checks and has no unpushed commits, then switches to the trunk and deletes every layer's branch, local and remote, once all of them have merged. Set the method option to merge (default), squash or rebase. Needs the GitHub CLI, signed in.

🔀 PR #42  ⏳ 3/5 checks  waiting                            ← band
PR #42  ✗ 1 failing: SonarCloud  blocked                     ← red
PR #42  ✓ 5/5 checks  ready · /merge                         ← green
PR #42  stack 2/3  ✓ 5/5 checks  ready · /merge              ← a gh-stack layer
PR #42 checks passed, ready to /merge                        ← toast

<sub>📁 plugins/pr-pilot</sub>

📝 release-notes

In an Xcode project, draws the app's MARKETING_VERSION and build above the prompt with the commits since the last release. When a turn, or anything between sessions, changes the version, Haiku drafts App Store "What's New" notes from those commits and a toast says where they went. With fastlane they go to fastlane/metadata/<locale>/release_notes.txt for the default locale, else en-US, else the first. Without fastlane they become a ## <version> section in CHANGELOG.md, and a section that is already there is left alone. The last release is the newest tag, or the commit that set the previous version when the repository has no tags. The first time it sees a repository it only records the version. /draft-release-notes drafts the notes again, replacing what is there.

📝 v2.4.0 (41)  12 commits since v2.3.0  notes drafted      ← band
Release notes for 2.4.0 drafted in fastlane/metadata/en-US/release_notes.txt   ← toast

<sub>📁 plugins/release-notes</sub>

📱 resource-watch

Shows which Claude Code session is using each shared thing on the machine, so parallel agents do not fight over one. Booted Apple simulators and Claude Design projects are the baseline; the Xcode app sits on top.

  • Simulators: in an Xcode project, or once the session has used one, every booted simulator with the sessions holding it. A session takes a simulator when simctl or xcodebuild names it by UDID or name, or uses booted or an Xcode MCP run while only one is booted; commands that only look, as simctl list or xcodebuild -showdestinations, do not count.
  • Claude Design: a project, on any claude-design tool call on it.
  • Xcode app: on Xcode MCP calls that change its shared state: switching scheme, run destination or test plan, opening or closing a workspace, building, running, testing or stopping.

A hold lasts the hold option's minutes (10 by default) after the last call, and ends with the session. Sessions share their holds as files in ~/.claude/resource-watch/, labelled by their project's folder. A simulator call on one another session holds is refused the first time: the holding session is asked, and the agent is told to wait for its reply, then repeat the call to go ahead, and never to switch to or create another simulator without asking you. The holder tells the waiting sessions once it is done: after StopProject or DeviceInteractionEndSession, when its hold runs out, or when it ends. For a Claude Design project or the Xcode app nothing is refused: it toasts, messages the holding session so its agent can reply asking it to wait, and tells its own agent who holds it; a session that ends tells the sessions it shared with.

📱 iPhone 18 Pro (iOS 27.2) · this session  🛠 the Xcode app · ⚠ also the web session   ← band
iPhone 18 Pro is in use by the web session; asked it, waiting                            ← toast

<sub>📁 plugins/resource-watch</sub>

🗜 smart-compact

Compacts the conversation between turns, never during one, weighing several factors against the context's fill. Past the threshold option (60, 70 default, or 80%) it compacts about ten seconds after a turn ends. It compacts sooner when compacting is cheapest or most worth it:

  • Checkpoints, 20 points lower: a turn that committed, opened or merged a PR (gh pr, gh stack), had a plan approved, finished its todo list, or switched branch or worktree. The summary is told what to keep for each, such as the approved plan verbatim. A checkpoint counts only until the next turn starts. Set checkpoints to off to ignore them.
  • Cache cooling, 20 points lower in the last minute before the prompt cache goes cold, so the summary still reads the conversation from cache. Once the cache is cold it does not compact sooner, since the summary would re-read the whole conversation uncached.
  • 5-hour limit, 10 points lower while it is at 80% or more.

Nothing takes it under the floor option (30, 40 default, or 50%), and it tries at most once per idle stretch. The ttl option defaults to auto: 1h on a subscription, 5m on an API key or cloud provider. Set it to 5m while a subscription draws on usage credits, or to 1h when you set a one-hour cache yourself.

Set continue to tasks to carry on after compacting while todos or tasks are still open. It sends Continue with the remaining tasks., unless the turn was interrupted or ended on a question.

Compacted at 52%: committed                                  ← toast
Compacted at 55%: cache cooling                              ← toast
Compacted at 74%: context high, 5h limit at 86%              ← toast
Compacted at 75%: context high; continuing                   ← toast

<sub>📁 plugins/smart-compact</sub>

⛅ token-weather

Draws a forecast of the context window above the prompt, with tokens used, a sparkline of the last 12 turns and the change since the last turn.

⛅ Cloudy  38% of context  76.4k / 200k   last turns ▁▂▂▃▄▄▅▆  ▲ +4.1k last turn

| 🌞 Clear | ⛅ Cloudy | ☔ Showers | ⚡ Storm | 🚨 Compact soon | | :-: | :-: | :-: | :-: | :-: | | under 25% | under 50% | under 75% | under 90% | 90% and up |

<sub>📁 plugins/token-weather</sub>


MIT © Tartiner Labs

Source 2 files
hooks/register.tsx 263 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Release } from '../types'
5
6const PROMPT = `Write the App Store "What's New" text for version {version} of an Apple app from the commit subjects below, newest first.
7
8Write for the people who use the app, not its developers: one short line per change they would notice, each starting with "- ", grouped new features first, then improvements, then fixes. Leave out refactors, tests, CI, dependency bumps, version bumps and other internal work. If nothing is user-facing, reply "- Bug fixes and performance improvements." Reply with the lines only.
9
10{commits}`
11
12// App Store Connect's limit for "What's New".
13const MAX_NOTES = 4000
14const MAX_COMMITS = 200
15const FALLBACK = '- Bug fixes and performance improvements.'
16
17// Held by the host, so a hot reload keeps the band.
18const current = atom({ plugin: 'release-notes', key: 'release' } as const, null as Release | null)
19
20/** What the mod remembers per repository across sessions. */
21type Seen = { version: string; draftedFor?: string }
22
23type Scan = { root: string; subjects: string[]; release: Release }
24
25/** Compares dotted versions numerically, so 2.10 sorts after 2.9. */
26export function compareVersions(a: string, b: string) {
27  const left = a.split('.').map(Number)
28  const right = b.split('.').map(Number)
29  for (let i = 0; i < Math.max(left.length, right.length); i++) {
30    const diff = (left[i] || 0) - (right[i] || 0)
31    if (diff) return diff
32  }
33
34  return 0
35}
36
37const highest = (values: string[]) => values.sort(compareVersions).at(-1) ?? null
38
39/**
40 * The app's version and build from a `project.pbxproj` (`KEY = 1.0;`) or the JSON `project.xcproj`
41 * (`"KEY": "1.0"`). Targets can disagree (a test target left at 1.0), so the highest wins; anything but
42 * digits and dots, such as a value set from another build setting (`$(...)`), is skipped.
43 */
44export function parseVersions(project: string) {
45  const values = (key: string) =>
46    [...project.matchAll(new RegExp(`\\b${key}"?\\s*[=:]\\s*"?([^";,\\n]+)"?`, 'g'))].map(m => m[1]!.trim()).filter(v => /^\d+(\.\d+)*$/.test(v))
47
48  return { version: highest(values('MARKETING_VERSION')), build: highest(values('CURRENT_PROJECT_VERSION')) }
49}
50
51/** The fastlane locale the notes go to: `default` (deliver's fallback for every language), then en-US, then the first. */
52export function pickLocale(names: string[]) {
53  // Locale folders only, not review_information or trade_representative_contact_information.
54  const locales = names.filter(n => /^(default|[a-z]{2,3}(-[A-Za-z0-9]+)*)$/.test(n)).sort()
55
56  return locales.find(n => n === 'default') ?? locales.find(n => n === 'en-US') ?? locales[0] ?? null
57}
58
59const heading = (version: string) => new RegExp(`^## \\[?${version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]?(?=[\\s(]|$)`, 'm')
60
61/** True when a changelog already holds a section for `version`. */
62export const hasSection = (changelog: string, version: string) => heading(version).test(changelog)
63
64/** Puts `notes` under a `## version` section: replacing that section's body, or above the newest section. */
65export function withSection(changelog: string, version: string, notes: string) {
66  const section = `## ${version}\n\n${notes}\n`
67  const found = heading(version).exec(changelog)
68  if (found) {
69    const rest = changelog.slice(found.index + found[0].length)
70    const next = rest.search(/^## /m)
71
72    return changelog.slice(0, found.index) + section + (next < 0 ? '' : `\n${rest.slice(next)}`)
73  }
74  const newest = changelog.search(/^## /m)
75  if (newest >= 0) return `${changelog.slice(0, newest)}${section}\n${changelog.slice(newest)}`
76
77  return `${changelog.trimEnd() || '# Changelog'}\n\n${section}`
78}
79
80/** Drops control characters but newlines, so a reply cannot carry terminal escapes, and holds it to the App Store limit. */
81export function cleanNotes(text: string) {
82  return text.replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/g, '').trim().slice(0, MAX_NOTES).trim()
83}
84
85async function git($: EngineInterface, root: string, ...args: string[]) {
86  const ran = await $.process.run(['git', '-C', root, ...args], { timeoutMs: 15_000 }).catch(() => null)
87
88  return ran?.exitCode === 0 ? ran.stdout.trim() : null
89}
90
91/** The tag or commit the notes count from: the newest tag that is not this version's, else the commit that set the previous version. */
92/**
93 * True when `path`, or its folder while it does not exist yet, resolves inside the repository, so a
94 * symbolic link in a cloned repository cannot send a write elsewhere.
95 */
96async function isInside($: EngineInterface, root: string, path: string) {
97  const resolve = (p: string) => $.fs.stat(p, { resolve: true }).catch(() => undefined)
98  const realRoot = (await resolve(root))?.realPath
99  const own = await resolve(path)
100  // A dangling link has a stat but no realPath, and is refused.
101  const real = own ? own.realPath : (await resolve(path.slice(0, path.lastIndexOf('/'))))?.realPath
102
103  return realRoot !== undefined && real !== undefined && (real.startsWith(`${realRoot}/`) || (!own && real === realRoot))
104}
105
106async function findSince($: EngineInterface, root: string, project: string, version: string) {
107  let tag = await git($, root, 'describe', '--tags', '--abbrev=0')
108  // Tagged already, so count from the release before it.
109  if (tag && tag.replace(/^v/, '') === version) tag = await git($, root, 'describe', '--tags', '--abbrev=0', '--end-of-options', `${tag}^`)
110  if (tag) return tag
111
112  const bumps = (await git($, root, 'log', '-n', '50', '--format=%h', '-G', 'MARKETING_VERSION', '--', project))?.split('\n').filter(Boolean) ?? []
113  for (const sha of bumps) {
114    const then = await git($, root, 'show', `${sha}:${project}`)
115    if (then && parseVersions(then).version !== version) return sha
116  }
117
118  return null
119}
120
121async function findTarget($: EngineInterface, root: string, project: string) {
122  // fastlane sits beside the .xcodeproj, or at the repository root.
123  const beside = project.split('/').slice(0, -2).join('/')
124  for (const dir of [...new Set([beside, ''])]) {
125    const metadata = `${dir ? `${dir}/` : ''}fastlane/metadata`
126    const entries = await $.fs.list(`${root}/${metadata}`).catch(() => [])
127    const locale = pickLocale(entries.filter(e => e.kind === 'dir').map(e => e.name))
128    if (locale) return { target: `${metadata}/${locale}/release_notes.txt`, isFastlane: true }
129  }
130
131  return { target: 'CHANGELOG.md', isFastlane: false }
132}
133
134async function scan($: EngineInterface): Promise<Scan | null> {
135  const root = await git($, '.', 'rev-parse', '--show-toplevel')
136  if (!root) return null
137  const project = (await git($, root, 'ls-files', '*.xcodeproj/project.pbxproj', '*.xcodeproj/project.xcproj'))
138    ?.split('\n')
139    .filter(p => p && !/(^|\/)(Pods|Carthage)\//.test(p))
140    .sort((a, b) => a.split('/').length - b.split('/').length || a.length - b.length)[0]
141  if (!project) return null
142  const { version, build } = parseVersions(await $.fs.read(`${root}/${project}`).catch(() => ''))
143  if (!version) return null
144
145  const since = await findSince($, root, project, version)
146  const range = since ? `${since}..HEAD` : 'HEAD'
147  const subjects = (await git($, root, 'log', '--no-merges', '-n', String(MAX_COMMITS), '--format=%s', '--end-of-options', range))?.split('\n').filter(Boolean) ?? []
148  const commits = Number(await git($, root, 'rev-list', '--count', '--no-merges', '--end-of-options', range)) || subjects.length
149  const { target, isFastlane } = await findTarget($, root, project)
150  const seen = (await $.store.get(`repo:${root}`)) as Seen | undefined
151  const isDrafted = isFastlane
152    ? seen?.draftedFor === version
153    : hasSection(await $.fs.read(`${root}/${target}`).catch(() => ''), version)
154
155  return { root, subjects, release: { version, build, since, commits, target, isDrafted } }
156}
157
158/** Drafts the notes with Haiku and writes them to the target; resolves the notes, or why it wrote nothing. */
159async function draft($: EngineInterface, { root, subjects, release }: Scan): Promise<{ notes: string } | { error: string }> {
160  const path = `${root}/${release.target}`
161  if (!(await isInside($, root, path))) return { error: `${release.target} leads outside the repository, so it was left alone.` }
162
163  let notes = FALLBACK
164  if (subjects.length) {
165    const reply = await $.model.complete({
166      model: 'haiku',
167      prompt: PROMPT.replace('{version}', release.version).replace('{commits}', subjects.map(s => `- ${s}`).join('\n')),
168      maxTokens: 1200,
169      timeoutMs: 30_000,
170    })
171    notes = reply.isAnswered ? cleanNotes(reply.text) : ''
172    if (!notes) return { error: 'the model gave no answer.' }
173  }
174
175  if (release.target.endsWith('release_notes.txt')) await $.fs.write(path, `${notes}\n`)
176  else await $.fs.write(path, withSection(await $.fs.read(path).catch(() => ''), release.version, notes))
177  const seen = ((await $.store.get(`repo:${root}`)) ?? {}) as Seen
178  await $.store.set(`repo:${root}`, { ...seen, version: release.version, draftedFor: release.version })
179  await update($, current, () => ({ ...release, isDrafted: true }))
180
181  return { notes }
182}
183
184// Skips a check while one runs, so a turn ending mid-draft does not draft twice.
185let isChecking = false
186
187/** Rescans, and drafts on its own once the version has changed since this repository was last seen. */
188async function check($: EngineInterface) {
189  if (isChecking) return
190  isChecking = true
191  try {
192    const found = await scan($)
193    await update($, current, () => found?.release ?? null)
194    if (!found) return
195
196    const key = `repo:${found.root}`
197    const seen = (await $.store.get(key)) as Seen | undefined
198    // The first sight of a repository is its baseline, not a bump.
199    if (seen?.version === found.release.version) return
200    await $.store.set(key, { ...seen, version: found.release.version })
201    if (!seen || found.release.isDrafted) return
202
203    const drafted = await draft($, found)
204    $.ui.toast(
205      'notes' in drafted
206        ? `Release notes for ${found.release.version} drafted in ${found.release.target}`
207        : `Release notes for ${found.release.version} not drafted: ${drafted.error}`,
208      { timeoutMs: 8000 },
209    )
210  } finally {
211    isChecking = false
212  }
213}
214
215export const register: Register = on => {
216  on('session.start', async ($, e, next) => {
217    const result = await next(e)
218    await $.command.register({ name: 'draft-release-notes', description: 'Draft App Store release notes for the current version from the commits since the last release' })
219    // On a timer, so neither the session's start nor a turn's end waits on the model.
220    $.clock.after(0, () => check($))
221
222    return result
223  })
224
225  // A turn may have bumped the version, by hand or through fastlane.
226  on('turn.complete', async ($, e, next) => {
227    const result = await next(e)
228    if (e.agentId === undefined) $.clock.after(0, () => check($))
229
230    return result
231  })
232
233  on('command.run', { command: 'draft-release-notes' }, async $ => {
234    const found = await scan($)
235    await update($, current, () => found?.release ?? null)
236    if (!found) return { text: 'No Xcode project with a MARKETING_VERSION in this repository.' }
237    const drafted = await draft($, found)
238    if (!('notes' in drafted)) return { text: `Could not draft the release notes: ${drafted.error}` }
239
240    return { text: `Release notes for ${found.release.version} written to ${found.release.target}:\n\n${drafted.notes}` }
241  })
242
243  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
244    const release = await read($, current)
245    if (e.props.hasSurvey || !release) return next(e)
246
247    const { Box, Text } = $.ui.resolve(e)
248    // Stack above whatever the plugins beneath draw, so other bands stay.
249    const below = await next(e)
250
251    return (
252      <Box flexDirection="column">
253        <Box flexDirection="row" paddingX={1} gap={2}>
254          <Text dimColor>📝 v{release.version}{release.build ? ` (${release.build})` : ''}</Text>
255          <Text dimColor>{release.commits} commits since {release.since ?? 'the start'}</Text>
256          {release.isDrafted ? <Text color="green">notes drafted</Text> : <Text color="gray">notes not drafted · /draft-release-notes</Text>}
257        </Box>
258        {below}
259      </Box>
260    )
261  })
262}
263
types/index.d.ts 22 lines
1/** Where the Xcode project in the working directory stands, as the last scan found it. */
2export type Release = {
3  /** `MARKETING_VERSION`, as in 2.4.0. */
4  version: string
5  /** `CURRENT_PROJECT_VERSION`; null when the project sets none. */
6  build: string | null
7  /** The tag, or short commit, the notes count from; null when the whole history counts. */
8  since: string | null
9  /** Commits since then, merges left out. */
10  commits: number
11  /** The file the notes go to, relative to the repository root. */
12  target: string
13  /** True once that file holds notes for this version. */
14  isDrafted: boolean
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'release-notes': { release: Release | null }
20  }
21}
22