Offers to tag the session title with the PR you open and the Linear issue you're on, like <#123> or <MET-93>, asking before each change.

A Claude Code mod that tags a session's title with what it's working on, so the desktop sidebar tells you at a glance:
Fix login redirect <MET-93> <#123>
gh pr create, the GitHub tools' create-pull-request call, or the app's bind-PR call offers <#123>.you/met-93-fix-login), in a Linear tool call, or in your prompt (work on MET-93) offers <MET-93>.At the end of the turn it asks once, in Claude's question dialog: "Tag this session's title with <MET-93>?" Pick Add tag or Skip. A skipped tag is not offered again in that session; /refs add still adds it by hand.
Look-alikes such as UTF-8, ISO-9001 or fix-12 are skipped. To tag only your own teams, set Linear team keys (for example MET,ENG) in the plugin's settings. Up to four tags show; the newest win.
Run these two commands in a terminal, then restart Claude Code:
claude plugin marketplace add avishalom/title-refs
claude plugin install title-refs@title-refs --scope user
| Command | What it does |
|---|---|
/refs | Show this session's tags |
/refs add MET-93 or /refs add #123 | Add a tag by hand |
/refs remove MET-93 | Remove a wrong tag |
/refs clear | Remove all tags |
/note.hooks/register.ts 251 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const SERVER = 'ccd_session_mgmt'
4const MAX_TAGS = 4
5
6export type Refs = { prs: number[]; issues: string[]; declined?: string[] }
7
8const EMPTY: Refs = { prs: [], issues: [] }
9
10// Prefixes that look like issue keys but are standards, encodings or branch words.
11const NOT_ISSUES = new Set([
12 'UTF', 'ISO', 'SHA', 'MD', 'RFC', 'CVE', 'HTTP', 'TLS', 'SSL', 'ES', 'GPT', 'PEP',
13 'FIX', 'BUG', 'HOTFIX', 'FEAT', 'FEATURE', 'RELEASE', 'V', 'TASK', 'WIP', 'PR', 'X',
14 'IPV', 'H', 'COVID', 'MP', 'AES', 'RSA', 'ECMA', 'ANSI', 'WCAG', 'CSS', 'HTML', 'TOP',
15 'STEP', 'PHASE', 'PART', 'ROUND', 'PAGE', 'LINE', 'ITEM', 'DAY', 'WEEK', 'SPRINT', 'TEST',
16])
17
18const ISSUE = /(?<![A-Za-z0-9])([A-Za-z]{2,6})-(\d{1,6})(?![A-Za-z0-9])/g
19const UPPER_ISSUE = /(?<![A-Za-z0-9])([A-Z]{2,6})-(\d{1,6})(?![A-Za-z0-9])/g
20const PULL = /github\.com\/[\w.-]+\/[\w.-]+\/pull\/(\d+)/g
21// The app may hand titles back with < and > escaped, so match both spellings.
22const TAG = /\s*(?:<|<)(?:#\d+|[A-Z]{2,6}-\d+)(?:>|>)/g
23
24const teamsFrom = (raw: unknown): Set<string> | null => {
25 const keys = String(raw ?? '')
26 .split(/[\s,]+/)
27 .map(k => k.trim().toUpperCase())
28 .filter(Boolean)
29 return keys.length ? new Set(keys) : null
30}
31
32// Issue keys in text. Prompts need upper case (MET-93); branch names are lower case.
33export const findIssues = (text: string, teams: Set<string> | null, anyCase = false): string[] => {
34 const found: string[] = []
35 for (const m of text.matchAll(anyCase ? ISSUE : UPPER_ISSUE)) {
36 const key = (m[1] ?? '').toUpperCase()
37 const id = `${key}-${Number(m[2])}`
38 if (teams ? !teams.has(key) : NOT_ISSUES.has(key)) continue
39 if (!found.includes(id)) found.push(id)
40 }
41 return found
42}
43
44// True only when the command itself runs `gh pr create`, alone or after simple
45// `cd …` / `export …` steps, not when it merely mentions it (a heredoc, a grep).
46export const runsPrCreate = (command: string): boolean =>
47 /^\s*(?:(?:cd|export)\s+[^;&|\n]+?\s*&&\s*)*gh\s+pr\s+create\b/.test(command)
48
49export const findPulls = (text: string): number[] => {
50 const found: number[] = []
51 for (const m of text.matchAll(PULL)) {
52 const n = Number(m[1])
53 if (!found.includes(n)) found.push(n)
54 }
55 return found
56}
57
58// "<base> <MET-93> <#123>": the base with any old tags removed, then the tags.
59export const titled = (title: string, refs: Refs): string => {
60 const base = title.replace(TAG, '').trimEnd()
61 const inBase = (id: string) => new RegExp(`(?<![A-Za-z0-9])${id}(?![0-9])`, 'i').test(base)
62 const tags = [...refs.issues.filter(i => !inBase(i)), ...refs.prs.map(n => `#${n}`)].slice(-MAX_TAGS)
63 return tags.length ? `${base} ${tags.map(t => `<${t}>`).join(' ')}` : base
64}
65
66const merge = (refs: Refs, add: Partial<Refs>): Refs => ({
67 ...refs,
68 prs: [...refs.prs, ...(add.prs ?? []).filter(n => !refs.prs.includes(n))],
69 issues: [...refs.issues, ...(add.issues ?? []).filter(i => !refs.issues.includes(i))],
70})
71
72const label = (tag: string) => `<${tag}>`
73
74const key = async ($: EngineInterface) => `refs:${await $.session.id()}`
75
76const load = async ($: EngineInterface): Promise<Refs> =>
77 ((await $.store.get(await key($))) as Refs | undefined) ?? EMPTY
78
79const save = async ($: EngineInterface, refs: Refs) => {
80 if (refs.prs.length || refs.issues.length || refs.declined?.length) await $.store.set(await key($), refs)
81 else await $.store.delete(await key($))
82}
83
84const textOf = (res: { content: ReadonlyArray<{ type: string }> }): string =>
85 res.content.map(b => ('text' in b ? String(b.text) : '')).join('\n')
86
87// Rewrites the title only when it differs, so the app isn't asked needlessly.
88const sync = async ($: EngineInterface): Promise<string> => {
89 try {
90 const got = await $.mcp.call(SERVER, 'get_session', { session_id: 'self' })
91 if (got.isError) return 'session title unavailable'
92 const title = JSON.parse(textOf(got) || '{}').title
93 if (typeof title !== 'string') return 'session title unavailable'
94 const next = titled(title, await load($))
95 if (next === title) return 'title already up to date'
96 const set = await $.mcp.call(SERVER, 'set_session_title', { session_id: 'self', title: next })
97 return set.isError ? 'the app declined the rename' : `title: ${next}`
98 } catch {
99 return 'the app session server is not reachable'
100 }
101}
102
103// Refs spotted during a turn, asked about once the turn ends.
104let spotted: Partial<Refs> = {}
105
106const spot = (found: Partial<Refs>) => {
107 spotted = merge({ prs: spotted.prs ?? [], issues: spotted.issues ?? [] }, found)
108}
109
110// Asks once for every new ref, then tags the title with the ones approved.
111const offer = async ($: EngineInterface, found: Partial<Refs>) => {
112 const refs = await load($)
113 const declined = refs.declined ?? []
114 const fresh = [
115 ...(found.issues ?? []).filter(i => !refs.issues.includes(i) && !declined.includes(i)),
116 ...(found.prs ?? []).map(n => `#${n}`).filter(t => !refs.prs.includes(Number(t.slice(1))) && !declined.includes(t)),
117 ]
118 if (!fresh.length) {
119 await sync($)
120 return
121 }
122 const tags = fresh.map(label).join(' ')
123 let answer = 'Skip'
124 try {
125 answer = await $.ui.ask(`Tag this session's title with ${tags}?`, { options: ['Add tag', 'Skip'], header: 'Title tag' })
126 } catch {
127 return // dismissed, or nobody to ask: ask again next time
128 }
129 const isYes = answer === 'Add tag'
130 const prs = fresh.filter(t => t.startsWith('#')).map(t => Number(t.slice(1)))
131 const issues = fresh.filter(t => !t.startsWith('#'))
132 const next = isYes ? merge(refs, { prs, issues }) : { ...refs, declined: [...declined, ...fresh] }
133 await save($, next)
134 await sync($)
135}
136
137// Tagging is a side effect: it must never fail the prompt or tool call it rides on.
138const quietly = async (work: () => Promise<unknown>) => {
139 try {
140 await work()
141 } catch {
142 // ignored
143 }
144}
145
146const branchIssues = async ($: EngineInterface, teams: Set<string> | null): Promise<string[]> => {
147 try {
148 const run = await $.process.run(['git', 'branch', '--show-current'], {
149 cwd: await $.session.cwd(),
150 timeoutMs: 3000,
151 })
152 return run.exitCode === 0 ? findIssues(run.stdout.trim(), teams, true) : []
153 } catch {
154 return []
155 }
156}
157
158export const register: Register = (on, options) => {
159 const teams = teamsFrom(options.linearTeams)
160
161 on('session.start', async ($, e, next) => {
162 await $.command.register({
163 name: 'refs',
164 description: 'Show the PR and Linear tags on this session\'s title; add or remove one, or clear them',
165 argumentHint: '[add MET-93 | add #123 | remove <tag> | clear]',
166 immediate: true,
167 })
168 return next(e)
169 })
170
171 on('prompt.submit', async ($, e, next) => {
172 const ran = await next(e)
173 spot({ issues: findIssues(e.text, teams) })
174 return ran
175 })
176
177 on('tool.call', async ($, e, next) => {
178 const ran = await next(e)
179 if ('deny' in ran && ran.deny) return ran
180 // The tool's own arguments sit beside the reserved keys on the event.
181 const { tool: _tool, tool_use_id: _id, consent: _consent, ...args } = e as Record<string, unknown>
182 const input = JSON.stringify(args)
183 const output = ran.text ?? ''
184 if (!ran.isError) {
185 if (e.tool === 'Bash' && runsPrCreate(String(args.command ?? ''))) {
186 // gh prints the new PR's URL; take the last one it printed, never the command's text.
187 const pr = findPulls(output).at(-1)
188 if (pr !== undefined) spot({ prs: [pr] })
189 } else if (/create_pull_request|bind_pr/i.test(e.tool)) {
190 spot({ prs: findPulls(`${input}\n${output}`).slice(-1) })
191 }
192 }
193 if (/linear/i.test(e.tool)) spot({ issues: findIssues(input, teams) })
194 return ran
195 })
196
197 // Reapply after session-note rewrites the title, and pick up the branch.
198 on('command.run', async ($, e, next) => {
199 const ran = await next(e)
200 if (e.command === 'note' || e.command === 'note-color') await quietly(() => sync($))
201 return ran
202 })
203
204 on('turn.complete', async ($, e, next) => {
205 if (e.agentId === undefined) {
206 const found = spotted
207 spotted = {}
208 await quietly(async () => {
209 const issues = await branchIssues($, teams)
210 await offer($, merge({ prs: found.prs ?? [], issues: found.issues ?? [] }, { issues }))
211 })
212 }
213 return next(e)
214 })
215
216 on('command.run', { command: 'refs' }, async ($, e) => {
217 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
218 const arg = rest.join(' ')
219 const refs = await load($)
220 const show = (r: Refs) =>
221 [...r.issues, ...r.prs.map(n => `#${n}`)].map(t => `<${t}>`).join(' ') || 'no tags'
222
223 if (!verb) return { text: `Tags: ${show(refs)}` }
224 if (verb === 'clear') {
225 await save($, EMPTY)
226 return { text: `Tags cleared; ${await sync($)}.` }
227 }
228 const pr = /^#?(\d+)$/.exec(arg)
229 const issue = findIssues(arg.toUpperCase(), null)[0] ?? (/^[A-Z]{2,6}-\d+$/i.test(arg) ? arg.toUpperCase() : null)
230 if (verb === 'add' && (pr || issue)) {
231 const tag = pr ? `#${pr[1]}` : (issue as string)
232 const next = {
233 ...merge(refs, pr ? { prs: [Number(pr[1])] } : { issues: [issue as string] }),
234 declined: (refs.declined ?? []).filter(d => d !== tag),
235 }
236 await save($, next)
237 return { text: `Tags: ${show(next)}; ${await sync($)}.` }
238 }
239 if (verb === 'remove' && (pr || issue)) {
240 const next: Refs = {
241 ...refs,
242 prs: refs.prs.filter(n => !pr || n !== Number(pr[1])),
243 issues: refs.issues.filter(i => !issue || i !== issue),
244 }
245 await save($, next)
246 return { text: `Tags: ${show(next)}; ${await sync($)}.` }
247 }
248 return { text: 'Usage: /refs, /refs add MET-93, /refs add #123, /refs remove <tag>, /refs clear' }
249 })
250}
251