After an SDK release (v* tag): /release-check gathers what changed and asks Claude for a read-only audit of the sibling SDKs, docs and examples. Never acts…

Claude Code mods (function-hook plugins) for working across many repos and many sessions at once — plus a pet.
| Mod | What it does |
|---|---|
| where-am-i | A band above the prompt: 📁 repo 🌿 branch ↑2 ± 3 changed 🎯 task. /task <text> names what this session is doing. |
| done-ping | A macOS notification (repo, duration, your prompt) with a sound per kind: 🔔 Glass when a turn over 30 s is done, Basso when it failed, Submarine when Claude is waiting on you. Change them in SOUNDS. |
| repo-guard | Blocks push --force, pushes to main/master, reset --hard, clean -f, checkout ., branch -D, stash clear, rm -rf ~. Asks before Claude first edits a repo other than the session's own. |
| claude-pet | A pet that earns xp from your work, grows through five stages and unlocks achievements. Pick a species (/pet species: chick, cat, dog, dragon, dino, ocean, bug, plant, robot, moon) or your own emojis (/pet emoji 🦊, /pet emoji 🥚 🐣 🦊 🐺 🐉). Lives in the status line (🦮 xixi (・_・)📖 Lv7 ▰▱▱▱▱▱) with 18 moods that follow Claude: 💭 thinking, 📖 reading, ✍️ writing, ⚡ running, 🧪 testing, 🌐 browsing, 📣 delegating, ✋ waiting for you, 🔥 on a 25-call streak, 🌙 past 1am, 🎉 tests pass, 📦 commit/push, (×_×) error, (╬ಠ益ಠ) three in a row, bored after 5 idle minutes, asleep after 15. /pet for stats, /pet name <name>. |
| claude-mood | Claude's mood in the status line: 🤔 reading, ✍️ writing, 🧪 testing, 😤 after a few errors, 😌 when done — with 📖 ✏️ ⚡ 💥 counters. |
| control-tower | A row under where-am-i's, shown only while another session waits for you (a permission or a question): ✋ backend-ng 在等你 2m [tower]. /tower (or the button) opens a pane listing every Claude session on this machine, named repo · branch for worktrees. |
| tldr | After every long answer, a dim 💡 TL;DR line (Haiku) in the transcript, so a session you switch back to reads at a glance. |
| sdk-sync | Release audit for the SpatialReal SDKs. Never acts while you work: when a new v* tag appears it reminds you once; /release-check <repo> [tag] gathers the facts (public API files changed, CHANGELOG entry, commits in each sibling SDK / docs / examples since the release, version pins) and asks Claude for a read-only audit table. /release-check status lists unchecked releases; --facts skips the audit. Graph: plugins/sdk-sync/hooks/sdk-map.ts. |
| fortune | Programmer jokes after the working spinner (Sauteing… 🎲 删库跑路前,记得先 git push。); the day's first prompt shows 今日运势 (/fortune). Fridays never deploy. |
At a Claude Code terminal prompt:
/plugin marketplace add YuehengHan/my-claude-mods
/plugin install where-am-i@my-claude-mods
/plugin install done-ping@my-claude-mods
/plugin install repo-guard@my-claude-mods
/plugin install claude-pet@my-claude-mods
/plugin install claude-mood@my-claude-mods
Pick the user scope so they load in every session. Install only the ones you want.
Add a local clone as the marketplace instead, and Claude Code reads the plugins straight from the folder:
claude plugin marketplace add ~/Desktop/my-claude-mods
claude plugin install where-am-i@my-claude-mods --scope user
Claude Code runs an installed copy, so after editing a mod bump its version in plugin.json, then:
claude plugin marketplace update my-claude-mods
claude plugin update <mod>@my-claude-mods
and run /reload-plugins (or restart) in each open session.
Check and test a mod:
claude plugin validate plugins/<mod>
claude plugin test plugins/<mod>hooks/register.ts 283 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { BASE_DIR, SDK_MAP, bareVersion, isPublicApi, isReleaseTag, mentions, repoFromRemote, versionPattern } from './sdk-map.ts'
4import type { Target } from './sdk-map.ts'
5
6// Nothing here acts while you work. It notices a new release tag, says so once,
7// and on /release-check gathers the facts and asks Claude for a read-only audit.
8
9export type Release = { tag: string; date: string }
10
11export type TargetFacts = Target & {
12 exists: boolean
13 /** Commits touching `paths` on the default branch since the release. */
14 commits: string[]
15 pins: string[]
16 /** Files under `paths` naming the released version, when it should. */
17 versionHits: number | null
18}
19
20export type Facts = {
21 repo: string
22 path: string
23 release: Release
24 prev: Release | null
25 apiFiles: string[]
26 commits: string[]
27 changelog: string | null
28 targets: TargetFacts[]
29}
30
31const RELEASE_COMMAND =
32 /\b(git\s+tag\s+(-a\s+)?v\d|git\s+push\b[^\n]*--tags|git\s+push\s+\S+\s+v\d|npm\s+publish|pnpm\s+publish|gh\s+release\s+create|gradlew\s+\S*publish|pod\s+trunk\s+push|twine\s+upload|uv\s+publish|hatch\s+publish)/
33
34const KIND_LABEL: Record<Target['kind'], string> = {
35 parity: '对齐端',
36 docs: '文档',
37 examples: '示例',
38 consumer: '下游',
39 release: '发布仓',
40 codegen: '生成代码',
41}
42
43/** The signal the facts alone give; the audit decides for real. */
44export function signal(t: TargetFacts, version: string): string {
45 if (!t.exists) return '❔ 本地没有这个仓库'
46 const notes: string[] = []
47 if (t.commits.length === 0) notes.push('发版后没有相关提交')
48 if (t.versionHits === 0) notes.push(`没提到 ${version}`)
49 if (t.pins.length > 0 && !t.pins.some(p => mentions(p, version))) notes.push(`版本没跟到 ${version}`)
50 return notes.length ? `⚠️ ${notes.join(',')}` : '🟢 发版后有更新'
51}
52
53export function changelogSection(text: string, version: string): string | null {
54 const lines = text.split('\n')
55 const start = lines.findIndex(l => /^##\s/.test(l) && mentions(l, version))
56 if (start < 0) return null
57 const end = lines.findIndex((l, i) => i > start && /^##\s/.test(l))
58 return lines.slice(start, end < 0 ? undefined : end).join('\n').trim().slice(0, 3000)
59}
60
61export function formatFacts(f: Facts): string {
62 const v = bareVersion(f.release.tag)
63 const lines = [
64 `📦 ${f.repo} ${f.release.tag}(${f.release.date.slice(0, 10)})${f.prev ? ` ← ${f.prev.tag}` : '(首个版本)'}`,
65 ` 公开 API 改动 ${f.apiFiles.length} 个文件 · ${f.commits.length} 个提交 · CHANGELOG ${f.changelog ? '✓' : `❌ 没有 ${v} 的条目`}`,
66 '',
67 ...f.targets.map(t => {
68 const head = ` ${signal(t, v)} ${t.repo}(${KIND_LABEL[t.kind]})— ${t.why}`
69 const pins = t.pins.slice(0, 3).map(p => ` 📌 ${p}`)
70 const last = t.commits[0] ? [` ↳ ${t.commits[0]}${t.commits.length > 1 ? ` 等 ${t.commits.length} 个提交` : ''}`] : []
71 return [head, ...pins, ...last].join('\n')
72 }),
73 ]
74 return lines.join('\n')
75}
76
77export function auditPrompt(f: Facts): string {
78 const v = bareVersion(f.release.tag)
79 const spec = SDK_MAP[f.repo]
80 const range = f.prev ? `${f.prev.tag}..${f.release.tag}` : f.release.tag
81 return [
82 `[release-check] Read-only release audit: ${f.repo} ${f.release.tag} (released ${f.release.date.slice(0, 10)}${f.prev ? `, previous ${f.prev.tag}` : ''}).`,
83 spec?.note ?? '',
84 `Source repo: ${f.path}`,
85 f.apiFiles.length
86 ? `Public API files changed in ${range}:\n${f.apiFiles.map(x => `- ${x}`).join('\n')}\nSee them with: git -C "${f.path}" diff ${range} -- <file>`
87 : `No public API files changed in ${range} (check behaviour/bugfix parity from the commits and CHANGELOG instead).`,
88 `Commits in ${range}:\n${f.commits.slice(0, 30).join('\n') || '(none)'}`,
89 f.changelog ? `CHANGELOG section for ${v}:\n${f.changelog}` : `CHANGELOG has NO section for ${v} — report that.`,
90 'Facts gathered after `git fetch` (commits on each repo\'s default branch since the release date):',
91 ...f.targets.map(t =>
92 [
93 `- ${t.repo} [${t.kind}] ${t.why}`,
94 ` path: ${BASE_DIR}/${t.repo}; look at: ${t.paths.join(', ')}`,
95 ` commits since release: ${t.commits.length ? t.commits.slice(0, 5).join(' | ') : 'none'}`,
96 t.pins.length ? ` pins: ${t.pins.slice(0, 6).join(' | ')}` : '',
97 t.versionHits !== null ? ` files mentioning ${v}: ${t.versionHits}` : '',
98 ]
99 .filter(Boolean)
100 .join('\n'),
101 ),
102 [
103 'Your job, for each target above:',
104 '- parity: for every public API change and notable fix in this release, find the matching implementation in that SDK (name the symbol/file) or mark it missing.',
105 '- docs: are the new/changed APIs documented and is there a changelog entry for this version?',
106 '- examples / consumer / release: do they use this version (pins) and the current API (no removed/renamed calls)?',
107 '- codegen: has the generated/copied code been updated to this tag?',
108 'Read the default branch (origin/main) — `git -C <repo> show origin/main:<path>` — since local checkouts may be on other branches.',
109 'Do NOT modify, commit or push anything. Use parallel subagents per target if it helps.',
110 'Answer with one table — Target | Status (✅ done / ⚠️ partial / ❌ missing / ➖ n/a) | Evidence | What is left — then a short TODO list grouped by repo.',
111 ].join('\n'),
112 ]
113 .filter(Boolean)
114 .join('\n\n')
115}
116
117let home = ''
118let myRepo: string | null = null
119let releasedThisTurn = false
120let lastReminder = ''
121
122const repoPath = (repo: string) => `${home}/${BASE_DIR}/${repo}`
123
124async function git($: EngineInterface, repo: string, args: readonly string[], timeoutMs = 15_000): Promise<string> {
125 const r = await $.process.run(['git', '-C', repoPath(repo), ...args], { timeoutMs }).catch(() => null)
126 return r !== null && r.exitCode === 0 ? r.stdout : ''
127}
128
129async function init($: EngineInterface) {
130 home = (await $.env.get('HOME')) ?? ''
131 const r = await $.process.run(['git', 'remote', 'get-url', 'origin'], { timeoutMs: 5000 }).catch(() => null)
132 myRepo = r !== null && r.exitCode === 0 ? repoFromRemote(r.stdout) : null
133}
134
135async function releases($: EngineInterface, repo: string): Promise<Release[]> {
136 const out = await git($, repo, ['for-each-ref', '--sort=-creatordate', '--format=%(refname:short)|%(creatordate:iso-strict)', 'refs/tags'])
137 return out
138 .split('\n')
139 .map(l => l.split('|'))
140 .filter(([tag]) => tag !== undefined && isReleaseTag(tag))
141 .map(([tag, date]) => ({ tag: tag as string, date: date ?? '' }))
142}
143
144async function defaultRef($: EngineInterface, repo: string): Promise<string> {
145 return (await git($, repo, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])).trim() || 'HEAD'
146}
147
148async function targetFacts($: EngineInterface, t: Target, since: string, version: string): Promise<TargetFacts> {
149 const exists = (await git($, t.repo, ['rev-parse', '--git-dir'])).trim() !== ''
150 if (!exists) return { ...t, exists, commits: [], pins: [], versionHits: null }
151 const ref = await defaultRef($, t.repo)
152 const specs = t.paths.map(p => `:(glob)${p}`)
153 const log = await git($, t.repo, ['log', ref, `--since=${since}`, '--date=short', '--format=%h %ad %s', '--', ...specs])
154 const pins = t.pin
155 ? (await git($, t.repo, ['grep', '-n', '-E', t.pin, ref, '--', ...specs]))
156 .split('\n')
157 .filter(Boolean)
158 .map(l => l.replace(`${ref}:`, '').replace(/\s+/g, ' ').trim())
159 : []
160 const hits = t.mentionsVersion
161 ? (await git($, t.repo, ['grep', '-l', '-E', versionPattern(version), ref, '--', ...specs])).split('\n').filter(Boolean).length
162 : null
163 return { ...t, exists, commits: log.split('\n').filter(Boolean), pins, versionHits: hits }
164}
165
166async function gather($: EngineInterface, repo: string, tag?: string): Promise<Facts | string> {
167 const spec = SDK_MAP[repo]
168 if (spec === undefined) return `${repo} 不在 SDK 图里。可选:${Object.keys(SDK_MAP).join(', ')}`
169 const repos = [...new Set([repo, ...spec.targets.map(t => t.repo)])]
170 await Promise.all(repos.map(r => git($, r, ['fetch', '--quiet', '--tags', 'origin'], 30_000)))
171
172 const all = await releases($, repo)
173 const at = tag ? all.findIndex(r => r.tag === tag) : 0
174 const release = all[at]
175 if (release === undefined) return tag ? `${repo} 没有 tag ${tag}。` : `${repo} 还没有 v* 发版 tag。`
176 const prev = all[at + 1] ?? null
177 const range = prev ? `${prev.tag}..${release.tag}` : release.tag
178
179 const changed = prev ? await git($, repo, ['diff', '--name-only', prev.tag, release.tag]) : ''
180 const commits = await git($, repo, ['log', '--format=%h %s', range])
181 const changelogText = spec.self.changelog ? await git($, repo, ['show', `${release.tag}:${spec.self.changelog}`]) : ''
182 const version = bareVersion(release.tag)
183
184 return {
185 repo,
186 path: repoPath(repo),
187 release,
188 prev,
189 apiFiles: changed.split('\n').filter(f => f && isPublicApi(repo, f)),
190 commits: commits.split('\n').filter(Boolean),
191 changelog: spec.self.changelog ? changelogSection(changelogText, version) : null,
192 targets: await Promise.all(spec.targets.map(t => targetFacts($, t, release.date, version))),
193 }
194}
195
196/** Releases nobody has run /release-check on yet. A repo seen for the first time is just recorded. */
197async function unchecked($: EngineInterface): Promise<{ repo: string; tag: string }[]> {
198 const out: { repo: string; tag: string }[] = []
199 for (const repo of Object.keys(SDK_MAP)) {
200 const latest = (await releases($, repo))[0]
201 if (latest === undefined) continue
202 const checked = await $.store.get(`checked:${repo}`)
203 if (checked === undefined) await $.store.set(`checked:${repo}`, latest.tag)
204 else if (checked !== latest.tag) out.push({ repo, tag: latest.tag })
205 }
206 return out
207}
208
209async function remind($: EngineInterface) {
210 const due = await unchecked($)
211 const text = due.map(d => `${d.repo} ${d.tag}`).join('、')
212 if (due.length === 0 || text === lastReminder) return
213 lastReminder = text
214 $.ui.toast(`📦 有新版本还没做发版核对:${text} → /release-check ${due[0]?.repo ?? ''}`, { timeoutMs: 12_000 })
215}
216
217async function runCheck($: EngineInterface, args: string): Promise<string> {
218 const words = args.trim().split(/\s+/).filter(Boolean)
219 const factsOnly = words.includes('--facts')
220 const [first, second] = words.filter(w => w !== '--facts')
221
222 if (first === 'status' || (first === undefined && (myRepo === null || SDK_MAP[myRepo] === undefined))) {
223 const rows: string[] = []
224 for (const repo of Object.keys(SDK_MAP)) {
225 const latest = (await releases($, repo))[0]
226 const checked = await $.store.get(`checked:${repo}`)
227 rows.push(
228 latest
229 ? ` ${checked === latest.tag ? '✅' : '📦'} ${repo.padEnd(28)} ${latest.tag.padEnd(16)} ${latest.date.slice(0, 10)}${checked === latest.tag ? '' : ' ← 未核对'}`
230 : ` ➖ ${repo.padEnd(28)} (no v* tags)`,
231 )
232 }
233 return ['🔗 SDK 发版核对状态(本地 tag)', ...rows, '', '/release-check <repo> [tag] [--facts]'].join('\n')
234 }
235
236 const repo = first ?? (myRepo as string)
237 const facts = await gather($, repo, second)
238 if (typeof facts === 'string') return facts
239 await $.store.set(`checked:${repo}`, facts.release.tag)
240 lastReminder = ''
241 if (!factsOnly) {
242 // Not awaited: the audit turn can start only after this command returns.
243 void $.prompt.submit({ text: auditPrompt(facts) })
244 }
245 return `${formatFacts(facts)}\n\n${factsOnly ? '(只列事实;去掉 --facts 让 Claude 逐项核对)' : '🔍 已交给 Claude 做只读核对…'}`
246}
247
248function safely(work: Promise<unknown>) {
249 return work.catch(() => undefined)
250}
251
252export const register: Register = on => {
253 on('session.start', async ($, e, next) => {
254 await $.command.register({
255 name: 'release-check',
256 description: '📦 After an SDK release: are the other SDKs, docs and examples caught up? (read-only)',
257 argumentHint: '[repo|status] [tag] [--facts]',
258 })
259 await safely(init($))
260 void safely(remind($))
261 $.clock.every(10 * 60_000, () => void safely(remind($)))
262 return next(e)
263 })
264
265 on('command.run', { command: 'release-check' }, async ($, e) => ({ text: await runCheck($, e.args) }))
266
267 // Watch, never act: a release made in this session gets one reminder when the turn ends.
268 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
269 const ran = await next(e)
270 if (RELEASE_COMMAND.test(e.command) && ran.deny === undefined && !ran.isError) releasedThisTurn = true
271 return ran
272 })
273
274 on('turn.complete', async ($, e, next) => {
275 const done = await next(e)
276 if (e.agentId === undefined && releasedThisTurn) {
277 releasedThisTurn = false
278 void safely(remind($))
279 }
280 return done
281 })
282}
283hooks/sdk-map.ts 263 lines1// The SpatialReal SDK graph for release checks: for each repo that ships
2// releases (v* tags), its public API and who must follow a release. Repos are
3// named by their GitHub remote and live under ~/<BASE_DIR>/<repo>.
4
5export const BASE_DIR = 'Desktop/SpatialReal'
6
7/**
8 * - parity: a sibling SDK that must offer the same API
9 * - docs: pages that document it
10 * - examples: sample apps that pin its version
11 * - consumer: a package that depends on it
12 * - release: a distribution repo republishing it
13 * - codegen: code generated or copied from it
14 */
15export type TargetKind = 'parity' | 'docs' | 'examples' | 'consumer' | 'release' | 'codegen'
16
17export type Target = {
18 repo: string
19 kind: TargetKind
20 /** What to verify there, in a few words. */
21 why: string
22 /** git pathspecs (glob) in that repo to look at. */
23 paths: readonly string[]
24 /** A POSIX ERE (git grep -E: no \\s, use [[:space:]]) for the line pinning the version. */
25 pin?: string
26 /** Whether the released version string should appear in `paths`. */
27 mentionsVersion?: boolean
28}
29
30export type RepoSpec = {
31 /** Public API, as globs relative to the repo root. */
32 api: readonly string[]
33 exclude?: readonly string[]
34 /** In the repo itself, where the version and changelog live. */
35 self: { version: string; changelog?: string }
36 /** One line the auditor reads about this repo's role. */
37 note: string
38 targets: readonly Target[]
39}
40
41const ANDROID = 'sdk/src/main/java/ai/spatialreal/android'
42const IOS = 'Sources/SpatialRealSDK'
43
44const sharedDocs = (sdk: string): Target => ({
45 repo: 'spatialreal-docs',
46 kind: 'docs',
47 why: `cross-SDK pages still right for ${sdk}`,
48 paths: ['resources/error-codes.mdx', 'resources/client-error.mdx', 'resources/migration-guide.mdx', 'overview/changelog.mdx'],
49})
50
51export const SDK_MAP: Record<string, RepoSpec> = {
52 'web-sdk': {
53 api: [
54 'index.ts',
55 'facade/**',
56 'agent/**',
57 'types/**',
58 'core/Avatar{,Controller,SDK,Manager,View}.ts',
59 'config/environments.ts',
60 'audio/audio-context.ts',
61 'vite.ts',
62 'next.ts',
63 ],
64 self: { version: 'package.json → version', changelog: 'CHANGELOG.md' },
65 note: 'Web SDK is the reference implementation; Android and iOS port its facade (release-workflow: run the cross-SDK alignment check; missing parity → implement or open tracked follow-ups). Removed APIs stay, wrapped with deprecate().',
66 targets: [
67 { repo: 'android-sdk', kind: 'parity', why: 'same facade API / semantics / error codes', paths: [`${ANDROID}/facade/**`, `${ANDROID}/*.kt`] },
68 { repo: 'ios-sdk', kind: 'parity', why: 'same facade API / semantics / error codes', paths: [`${IOS}/Facade/**`, `${IOS}/*.swift`] },
69 {
70 repo: 'spatialreal-docs',
71 kind: 'docs',
72 why: 'Web API reference + changelog',
73 paths: ['sdk-reference/web-sdk/**', 'avatar-integration/sdk-mode/web.mdx', 'avatar-integration/livekit/web-client.mdx', 'agent/quickstart.mdx'],
74 mentionsVersion: true,
75 },
76 sharedDocs('Web'),
77 {
78 repo: 'spatialreal-examples',
79 kind: 'examples',
80 why: 'web samples on the new version / API',
81 paths: ['agent/web/**', 'avatar-integration/sdk-mode/web/**', 'avatar-integration/host-mode/client/web/**', 'avatar-integration/livekit/web-client/**'],
82 pin: '"@spatialreal/web-sdk"[[:space:]]*:',
83 },
84 { repo: 'realtime_agent_framework', kind: 'consumer', why: 'clients/web-sdk submodule bumped', paths: ['clients/web-sdk', '.gitmodules'] },
85 ],
86 },
87
88 'android-sdk': {
89 api: [
90 `${ANDROID}/{Avatar,AvatarController,AvatarSDK,AvatarView}.kt`,
91 `${ANDROID}/facade/**`,
92 `${ANDROID}/assets/{meta,AvatarManager}.kt`,
93 `${ANDROID}/avatar/AvatarDataTypes.kt`,
94 `${ANDROID}/performance/**`,
95 ],
96 exclude: [`${ANDROID}/facade/ContainerRegistry.kt`, `${ANDROID}/facade/chat/AndroidChatHost.kt`],
97 self: { version: 'gradle.properties → SDK_VERSION_NAME / SDK_VERSION_CODE', changelog: 'CHANGELOG.md' },
98 note: 'Android ports the Web facade; API shape, lifecycle/state semantics and error codes stay aligned with Web and iOS. A bugfix means reviewing Web and iOS for the same class of issue.',
99 targets: [
100 { repo: 'web-sdk', kind: 'parity', why: 'reference impl has the same API / fix', paths: ['facade/**', 'index.ts'] },
101 { repo: 'ios-sdk', kind: 'parity', why: 'sibling port has the same API / fix', paths: [`${IOS}/Facade/**`, `${IOS}/*.swift`] },
102 {
103 repo: 'spatialreal-docs',
104 kind: 'docs',
105 why: 'Android API reference + changelog + install snippet',
106 paths: ['sdk-reference/android-sdk/**', 'avatar-integration/sdk-mode/android.mdx', 'snippets/android-sdk-install.mdx'],
107 mentionsVersion: true,
108 },
109 sharedDocs('Android'),
110 { repo: 'spatialreal-examples', kind: 'examples', why: 'android samples on the new version', paths: ['**/libs.versions.toml'], pin: '^spatialreal[[:space:]]*=' },
111 ],
112 },
113
114 'ios-sdk': {
115 api: [
116 `${IOS}/{Avatar,AvatarController,AvatarManager,AvatarSDK,AvatarView}.swift`,
117 `${IOS}/Facade/*.swift`,
118 `${IOS}/Facade/Chat/**`,
119 `${IOS}/Models/Config.swift`,
120 `${IOS}/Performance/**`,
121 `${IOS}/Services/{AvatarCache,AvatarLoadQueue}.swift`,
122 `${IOS}/Utils/Logger.swift`,
123 ],
124 exclude: [`${IOS}/Facade/Chat/DebugMic.swift`],
125 self: { version: 'SpatialRealSDK.podspec → spec.version', changelog: 'CHANGELOG.md' },
126 note: 'iOS ports the Web facade (CLAUDE.md: Web main is the reference; keep error codes, turn rules and close-code tables in step).',
127 targets: [
128 { repo: 'web-sdk', kind: 'parity', why: 'reference impl has the same API / fix', paths: ['facade/**', 'index.ts'] },
129 { repo: 'android-sdk', kind: 'parity', why: 'sibling port has the same API / fix', paths: [`${ANDROID}/facade/**`, `${ANDROID}/*.kt`] },
130 {
131 repo: 'ios-sdk-release',
132 kind: 'release',
133 why: 'binary republished: url + checksum + version in Package.swift, podspec, README',
134 paths: ['Package.swift', 'SpatialRealSDK.podspec', 'README.md'],
135 mentionsVersion: true,
136 },
137 {
138 repo: 'spatialreal-docs',
139 kind: 'docs',
140 why: 'iOS API reference + changelog + install snippet',
141 paths: ['sdk-reference/ios-sdk/**', 'avatar-integration/sdk-mode/ios.mdx', 'snippets/ios-sdk-install.mdx'],
142 mentionsVersion: true,
143 },
144 sharedDocs('iOS'),
145 { repo: 'spatialreal-examples', kind: 'examples', why: 'iOS samples on the new version', paths: ['**/project.pbxproj'], pin: 'minimumVersion' },
146 ],
147 },
148
149 'python-sdk': {
150 api: ['spatialreal/{__init__,config,errors,events,logid,session,version}.py'],
151 self: { version: 'spatialreal/version.py → __version__' },
152 note: 'Server-side host-mode SDK (PyPI spatialreal). livekit-plugins-spatialreal imports AvatarSession, LiveKitEgressConfig, Playback*, InterruptReason, new_avatar_session from it.',
153 targets: [
154 {
155 repo: 'livekit-plugins-spatialreal',
156 kind: 'consumer',
157 why: 'still compatible; pin raised if it needs the new API',
158 paths: ['livekit/plugins/spatialreal/**', 'pyproject.toml'],
159 pin: 'spatialreal[<>=~]',
160 },
161 {
162 repo: 'spatialreal-docs',
163 kind: 'docs',
164 why: 'Python SDK page + host-mode server guide',
165 paths: ['sdk-reference/python-sdk/**', 'avatar-integration/host-mode/server.mdx', 'avatar-integration/errors-and-recovery.mdx'],
166 mentionsVersion: true,
167 },
168 { repo: 'spatialreal-examples', kind: 'examples', why: 'host-mode server sample', paths: ['avatar-integration/host-mode/server/**'], pin: 'spatialreal[<>=~]' },
169 ],
170 },
171
172 'livekit-plugins-spatialreal': {
173 api: ['livekit/plugins/spatialreal/{__init__,avatar}.py'],
174 self: { version: 'livekit/plugins/spatialreal/version.py → __version__' },
175 note: 'LiveKit Agents plugin (PyPI livekit-plugins-spatialreal), built on python-sdk.',
176 targets: [
177 { repo: 'spatialreal-docs', kind: 'docs', why: 'LiveKit agent guide', paths: ['avatar-integration/livekit/agent.mdx'], mentionsVersion: true },
178 {
179 repo: 'spatialreal-examples',
180 kind: 'examples',
181 why: 'LiveKit agent sample',
182 paths: ['avatar-integration/livekit/agent/**'],
183 pin: 'livekit-plugins-spatialreal[<>=~]',
184 },
185 { repo: 'playground-livekit-agent', kind: 'consumer', why: 'playground agent', paths: ['pyproject.toml', 'agent.py'], pin: 'livekit-plugins-spatialreal[<>=~]' },
186 ],
187 },
188
189 'shared-proto': {
190 api: ['**/*.proto'],
191 self: { version: 'git tag (delivery is a tag)' },
192 note: 'The only place a contract is edited. web-sdk, python-sdk, backend-ng and inference-server regenerate from the tag automatically; android-sdk and ios-sdk do NOT (hand-maintained proto code that silently goes stale).',
193 targets: [
194 { repo: 'android-sdk', kind: 'codegen', why: 'MANUAL: hand-committed generated Java', paths: [`${ANDROID}/model/**`] },
195 { repo: 'ios-sdk', kind: 'codegen', why: 'MANUAL: hand-patched Driving.pb.swift', paths: [`${IOS}/Services/Driving.pb.swift`] },
196 { repo: 'web-sdk', kind: 'codegen', why: 'codegen PR from the tag merged', paths: ['proto/**', 'generated/**'] },
197 { repo: 'python-sdk', kind: 'codegen', why: 'codegen PR merged (proto/SHARED_PROTO_COMMIT)', paths: ['proto/**', 'spatialreal/proto/generated/**'] },
198 { repo: 'backend-ng', kind: 'codegen', why: 'codegen merged; cp/v1 → hack/check-proto-sync.sh', paths: ['api/generated/**'] },
199 { repo: 'inference-server', kind: 'codegen', why: 'Flame subset of driveningress/v2 re-synced', paths: ['proto/**', 'generated/**'] },
200 ],
201 },
202}
203
204function escape(text: string): string {
205 return text.replace(/[.+^$()|[\]\\]/g, '\\$&')
206}
207
208/** `**` any depth, `*` within a segment, `{a,b}` alternatives. */
209export function globToRegExp(glob: string): RegExp {
210 let re = ''
211 for (let i = 0; i < glob.length; i += 1) {
212 const c = glob[i] as string
213 if (c === '*' && glob[i + 1] === '*') {
214 re += '.*'
215 i += 1
216 if (glob[i + 1] === '/') i += 1
217 } else if (c === '*') {
218 re += '[^/]*'
219 } else if (c === '?') {
220 re += '[^/]'
221 } else if (c === '{') {
222 const end = glob.indexOf('}', i)
223 re += `(?:${glob.slice(i + 1, end).split(',').map(escape).join('|')})`
224 i = end
225 } else {
226 re += escape(c)
227 }
228 }
229 return new RegExp(`^${re}$`)
230}
231
232/** Whether `relPath` (relative to the repo root) is public API of `repo`. */
233export function isPublicApi(repo: string, relPath: string): boolean {
234 const spec = SDK_MAP[repo]
235 if (spec === undefined) return false
236 const hit = (globs: readonly string[] | undefined) => (globs ?? []).some(g => globToRegExp(g).test(relPath))
237 return hit(spec.api) && !hit(spec.exclude)
238}
239
240/** `git@github.com:SpatialReal-ai/web-sdk.git` → `web-sdk`. */
241export function repoFromRemote(url: string): string {
242 return url.trim().replace(/\.git$/, '').split(/[/:]/).pop() ?? ''
243}
244
245/** Release tags only: v1.2.3, v1.0.0-beta.3, v1.0.0-beta3; not facade-pre-rebase. */
246export function isReleaseTag(tag: string): boolean {
247 return /^v\d+\.\d+/.test(tag)
248}
249
250/** v1.0.0-beta.3 → 1.0.0-beta.3, the form changelogs and pins use. */
251export function bareVersion(tag: string): string {
252 return tag.replace(/^v/, '')
253}
254
255/** A version as a whole word: 1.0.0-beta3 is not in 1.0.0-beta39. */
256export function versionPattern(version: string): string {
257 return `${version.replace(/[.+^$()|[\]\\*?{}]/g, '\\$&')}([^0-9A-Za-z.]|$)`
258}
259
260export function mentions(text: string, version: string): boolean {
261 return new RegExp(versionPattern(version), 'm').test(text)
262}
263