Fingerprint the system prompt, tool descriptions, model and effort every session, and toast when something changed between versions, including changes the…

A Claude Code mod that fingerprints what the model is given each session (system prompt sections, built-in tool descriptions, model, effort) and tells you when that changed between sessions, including the changes no changelog lists.
drift: 2.1.284 → 2.1.285 · prompt +1.2k chars (2 sections) · Bash description changed · effort high → medium
> /drift
This session: 2026-10-06 13:00 UTC · 2.1.285 · claude-opus-5-5 · interactive · effort medium
system prompt ~2.6k tokens, of it 9 sections hooks can see (5.1k chars) · 31 built-in tools ~20.2k tokens
Since the last session on this model and mode (2026-10-05 18:20 UTC · 2.1.284 · claude-opus-5-5 · interactive · effort high):
version 2.1.284 → 2.1.285
effort high → medium
system prompt ~2.3k → ~2.6k tokens (+300)
~ section communication: 94 → 594 chars (+500)
+ section brief: +700 chars
~ tool Bash: description 1.5k → 1.8k chars (+307)
- tool RemoteTrigger: -2.8k chars
"Claude got dumber" is a recurring complaint, and people suspect silent changes to the prompt, tools, effort and model between versions. Some of them were real:
What exists today: Piebald-AI/claude-code-system-prompts dumps the prompt by hand per version, and the marginlab tracker benchmarks the model from outside. Neither tells you, in your own sessions, what changed in what your model was given. This mod does.
| Where | What |
|---|---|
| Toast | One line when the shipped prompt, a built-in tool or the effort differs from your last session on the same model and mode. Nothing when nothing changed |
/drift | The full diff: version, model, effort, the /context estimate of the system prompt, each section added, removed or changed with its size delta, each built-in tool added, removed, rewritten or moved behind ToolSearch. Against the last comparable session and against the last other version or model |
/drift history | The stored fingerprints: date, version, model, mode, effort, prompt size, tool count |
/drift dump | Writes the shipped sections and built-in tool descriptions in full to ~/.claude/drift-radar/<version>-<model>-<date>.md, so you can git diff two versions. Only when you run it |
A version bump alone does not toast: only a change in what the model reads does.
You need Claude Code with mods (function hooks); tested on 2.1.285 and 2.1.291. Mods are in early access: if the CLI says hooks modules are not turned on, start it as CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.
From the marketplace in this repo:
/plugin marketplace add Jvrd97/claude-drift-radar
/plugin install drift-radar@drift-radar
Or straight from disk, for one session:
git clone https://github.com/Jvrd97/claude-drift-radar.git
claude --plugin-dir ./claude-drift-radar
Change them in /config under the plugin, or in settings.json under pluginConfigs:
| Field | Default | Meaning |
|---|---|---|
toast | true | Show the one-line toast on drift. Off still stores fingerprints for /drift |
dumpDir | empty | Where /drift dump writes; empty means ~/.claude/drift-radar, ~/ is expanded |
prompt.section fires once per named section of the system prompt as the engine assembles it. The mod keeps each section's text for the session and passes it on unchanged.tool.describe fires once per tool. The mod keeps the description and deferred flag of built-in tools (provider engine); MCP and plugin tools are your setup and are skipped.turn.step gives the model and effort of each main-loop request; subagents are ignored.turn.complete), the mod builds the fingerprint: engine version, model, effort, mode (interactive or -p), the /context estimate of the system prompt and tools, and for each section and tool its length and a 12-digit SHA-256 (crypto.subtle). It takes another one if the model changes mid-session. A turn that ended on an API error is skipped: with an expired login the engine assembles fewer tools.~/.claude/plugins/store/drift-radar…json), the last 30. Text is not stored, only sizes and hashes.-p and the REPL assemble different prompts. Sections built from your files, folder, date or setup (memory, env_info_simple, session_guidance, mcp_instructions, output_style, language, scratchpad) are shown in /drift as "(yours)" and never toasted. Sections are matched by the name before the first :, so a renamed suffix (communication:L → communication) with the same text is no change./drift dump.What it can't do:
Inside Claude Code run /plugin-types .claude/types once: it writes the API types tsc reads. Then:
tsc -p .
claude plugin validate .claude-plugin/plugin.json
claude plugin test .
Мод снимает отпечаток того, что получает модель: секции системного промпта, описания встроенных инструментов, модель и effort.
/drift показывает полный дифф, /drift history — историю, /drift dump пишет текст промпта в файл для git diff.Зачем: жалобы «Claude поглупел» иногда оказываются правдой, а changelog пишет не всё. Мод ничего не отправляет в сеть.
MIT
hooks/register.ts 194 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DriftPrint } from '../types'
5import {
6 addPrint,
7 categoryTokens,
8 diffPrints,
9 dumpFolder,
10 dumpName,
11 dumpText,
12 effortText,
13 hasDrift,
14 historyText,
15 modeOf,
16 parseStored,
17 referenceFor,
18 report,
19 sectionPrints,
20 shippedPart,
21 summary,
22 toolPrints,
23 upsert,
24 USAGE,
25} from './logic'
26
27const COMMAND = 'drift'
28const STORE_KEY = 'fingerprints'
29const TOAST_MS = 10_000
30/** A built-in tool's provider; an MCP server's or a plugin's tool is the person's setup, not the build. */
31const ENGINE = 'engine'
32const PROMPT_ROW = 'System prompt'
33const TOOLS_ROW = 'System tools'
34
35const sections = atom({ plugin: 'drift-radar', key: 'sections' } as const, [])
36const tools = atom({ plugin: 'drift-radar', key: 'tools' } as const, [])
37const lastStep = atom({ plugin: 'drift-radar', key: 'lastStep' } as const, null)
38const printedModel = atom({ plugin: 'drift-radar', key: 'printedModel' } as const, null)
39const mode = atom({ plugin: 'drift-radar', key: 'mode' } as const, 'interactive')
40
41type Settings = { toasts: boolean; dumpDir: string }
42
43async function startSession($: EngineInterface, isInteractive: boolean): Promise<void> {
44 await update($, sections, () => [])
45 await update($, tools, () => [])
46 await update($, lastStep, () => null)
47 await update($, printedModel, () => null)
48 await update($, mode, () => modeOf(isInteractive))
49 await $.command.register({ name: COMMAND, description: 'Show what changed in the system prompt, tools, model and effort', argumentHint: '[history|dump]' })
50}
51
52async function noteSection($: EngineInterface, id: string, text: string | null): Promise<void> {
53 await update($, sections, list => (text === null ? list.filter(section => section.id !== id) : upsert(list, { id, text }, 'id')))
54}
55
56async function noteTool($: EngineInterface, name: string, description: string, isDeferred: boolean): Promise<void> {
57 await update($, tools, list => upsert(list, { name, description, isDeferred }, 'name'))
58}
59
60async function noteStep($: EngineInterface, model: string, effort: string | null): Promise<void> {
61 await update($, lastStep, () => ({ model, effort }))
62}
63
64/** Takes the session's fingerprint once per model, stores it, and toasts what drifted since the last comparable session. */
65async function fingerprint($: EngineInterface, settings: Settings): Promise<void> {
66 const step = await read($, lastStep)
67
68 if (step === null || (await read($, printedModel)) === step.model) {
69 return
70 }
71
72 await update($, printedModel, () => step.model)
73 const usage = await $.session.usage({ breakdown: 'summary' })
74 const categories = usage.context.breakdown?.categories ?? []
75 const print: DriftPrint = {
76 at: await $.clock.now(),
77 sessionId: await $.session.id(),
78 version: (await $.session.version()).version,
79 model: step.model,
80 effort: step.effort,
81 mode: await read($, mode),
82 promptTokens: categoryTokens(categories, PROMPT_ROW),
83 toolTokens: categoryTokens(categories, TOOLS_ROW),
84 sections: await sectionPrints(await read($, sections)),
85 tools: await toolPrints(await read($, tools)),
86 }
87 const history = parseStored(await $.store.get(STORE_KEY))
88 await $.store.set(STORE_KEY, addPrint(history, print))
89 const reference = referenceFor(history, print)
90
91 if (reference === null || !settings.toasts) {
92 return
93 }
94
95 const drift = shippedPart(diffPrints(reference, print))
96
97 if (hasDrift(drift)) {
98 $.ui.toast(summary(drift, print), { timeoutMs: TOAST_MS })
99 }
100}
101
102async function dump($: EngineInterface, settings: Settings): Promise<string> {
103 const seenSections = await read($, sections)
104 const seenTools = await read($, tools)
105
106 if (seenSections.length === 0 && seenTools.length === 0) {
107 return 'Nothing to dump yet: the prompt is assembled with the first request. Send one prompt, then run /drift dump.'
108 }
109
110 const folder = dumpFolder(settings.dumpDir, await $.env.get('HOME'))
111
112 if (folder === null) {
113 return 'HOME is not set: set dumpDir in the plugin settings to choose a folder.'
114 }
115
116 const version = (await $.session.version()).version
117 const model = (await read($, lastStep))?.model ?? (await $.session.model())
118 const at = await $.clock.now()
119 const path = `${folder}/${dumpName(version, model, at)}`
120 await $.fs.write(path, dumpText({ version, model, at, sections: seenSections, tools: seenTools }))
121
122 return `Wrote ${path}\nShipped sections and built-in tool descriptions in full; your own sections by size only. Compare two dumps with git diff --no-index.`
123}
124
125async function answer($: EngineInterface, args: string, settings: Settings): Promise<string> {
126 const action = args.trim().toLowerCase()
127
128 if (action === 'dump') {
129 return dump($, settings)
130 }
131
132 const history = parseStored(await $.store.get(STORE_KEY))
133 const sessionId = await $.session.id()
134
135 if (action === 'history') {
136 return historyText(history, sessionId)
137 }
138
139 if (action !== '') {
140 return USAGE
141 }
142
143 const mine = history.filter(print => print.sessionId === sessionId)
144 const current = mine[mine.length - 1] ?? history[history.length - 1] ?? null
145
146 return report(current, history, mine.length > 0)
147}
148
149export const register: Register = (on, options) => {
150 const settings: Settings = { toasts: options.toast !== false, dumpDir: String(options.dumpDir ?? '') }
151
152 on('session.start', async ($, e, next) => {
153 await startSession($, e.isInteractive)
154
155 return next(e)
156 })
157
158 on('prompt.section', async ($, e, next) => {
159 const result = await next(e)
160 await noteSection($, e.name, result.text)
161
162 return result
163 })
164
165 on('tool.describe', async ($, e, next) => {
166 const result = await next(e)
167
168 if (e.provider.plugin === ENGINE) {
169 await noteTool($, e.tool, result.description, (result.isDeferred ?? e.isDeferred) === true)
170 }
171
172 return result
173 })
174
175 on('turn.step', async function* ($, e, next) {
176 if (e.agentId === undefined) {
177 await noteStep($, e.model, effortText(e.effort))
178 }
179
180 return yield* next(e)
181 })
182
183 on('turn.complete', async ($, e, next) => {
184 // A request that failed (expired login, no network) can run with fewer tools; the next answered turn takes the print.
185 if (e.agentId === undefined && e.reason !== 'error') {
186 await fingerprint($, settings)
187 }
188
189 return next(e)
190 })
191
192 on('command.run', { command: COMMAND }, async ($, e) => ({ text: await answer($, e.args, settings) }))
193}
194hooks/logic.ts 500 lines1import type { DriftMode, DriftPrint, DriftScope, DriftSection, DriftSectionText, DriftTool, DriftToolText } from '../types'
2
3/** Fingerprints kept in the store; older ones are dropped first. */
4export const MAX_PRINTS = 30
5/** Hex digits of the SHA-256 kept per section or tool: enough to tell texts apart, short enough to store 30 sessions. */
6const HASH_HEX = 12
7/**
8 * A change in the /context estimate of the whole system prompt below this is
9 * left out of the toast: your folder path and the date move it by a few tokens.
10 */
11export const PROMPT_NOISE_TOKENS = 100
12const THOUSAND = 1000
13/** Up to this many changed tools are named in the toast; more are counted. */
14const TOOLS_NAMED = 3
15const DATE_END = 10
16const MINUTE_END = 16
17
18/**
19 * Sections whose text comes from you, not from the build: your CLAUDE.md files,
20 * your folder, platform and date, the skills and MCP servers you set up, your
21 * output style and language. Matched on the name before its first `:`.
22 */
23const SESSION_SECTIONS: readonly string[] = [
24 'memory',
25 'env_info',
26 'env_info_simple',
27 'session_guidance',
28 'mcp_instructions',
29 'output_style',
30 'language',
31 'scratchpad',
32]
33
34const baseOf = (id: string): string => id.split(':')[0] ?? id
35
36export const scopeOf = (id: string): DriftScope => (SESSION_SECTIONS.includes(baseOf(id)) ? 'session' : 'shipped')
37
38export type Change = { before: string; after: string }
39export type Count = { before: number; after: number }
40export type SectionChange = { id: string; formerId: string | null; scope: DriftScope; before: number; after: number }
41export type ToolChange = { name: string; before: number; after: number; isTextChanged: boolean; deferral: Change | null }
42
43export type Drift = {
44 version: Change | null
45 model: Change | null
46 effort: Change | null
47 promptTokens: Count | null
48 toolTokens: Count | null
49 sectionsAdded: DriftSection[]
50 sectionsRemoved: DriftSection[]
51 sectionsChanged: SectionChange[]
52 toolsAdded: DriftTool[]
53 toolsRemoved: DriftTool[]
54 toolsChanged: ToolChange[]
55}
56
57const toHex = (bytes: ArrayBuffer): string =>
58 [...new Uint8Array(bytes)].map(byte => byte.toString(16).padStart(2, '0')).join('')
59
60/** A short SHA-256 of the text: two texts with one hash are taken as equal. */
61export const shortHash = async (text: string): Promise<string> =>
62 toHex(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))).slice(0, HASH_HEX)
63
64export const effortText = (effort: string | number | undefined): string | null =>
65 effort === undefined ? null : String(effort)
66
67export const sectionPrints = (sections: readonly DriftSectionText[]): Promise<DriftSection[]> =>
68 Promise.all(
69 sections.map(async section => ({ id: section.id, scope: scopeOf(section.id), chars: section.text.length, hash: await shortHash(section.text) })),
70 )
71
72export const toolPrints = (tools: readonly DriftToolText[]): Promise<DriftTool[]> =>
73 Promise.all(
74 [...tools]
75 .sort((a, b) => a.name.localeCompare(b.name))
76 .map(async tool => ({ name: tool.name, chars: tool.description.length, hash: await shortHash(tool.description), isDeferred: tool.isDeferred })),
77 )
78
79/** Keeps the latest text per id: a section or tool described twice counts once. */
80export const upsert = <T, K extends keyof T>(list: readonly T[], item: T, key: K): T[] => [
81 ...list.filter(other => other[key] !== item[key]),
82 item,
83]
84
85/** 480, 1.2k, 31k: a size as the toast spells it. */
86export const compactChars = (chars: number): string => {
87 const size = Math.abs(chars)
88
89 if (size < THOUSAND) {
90 return String(size)
91 }
92
93 return `${(size / THOUSAND).toFixed(1).replace(/\.0$/, '')}k`
94}
95
96export const signed = (amount: number): string => (amount === 0 ? '±0' : `${amount > 0 ? '+' : '-'}${compactChars(amount)}`)
97
98const changeOf = (before: string | null, after: string | null): Change | null =>
99 before === after ? null : { before: before ?? 'none', after: after ?? 'none' }
100
101const countOf = (before: number | null, after: number | null): Count | null =>
102 before === null || after === null || before === after ? null : { before, after }
103
104const deferralOf = (isDeferred: boolean): string => (isDeferred ? 'deferred' : 'listed')
105
106/**
107 * Sections keyed by the name before the first `:`, so `communication:L` on one
108 * build and `communication` on the next are one section; a base two sections
109 * share keeps their full ids. The id is never sent to the model, the text is.
110 */
111const sectionsByKey = (sections: readonly DriftSection[]): Map<string, DriftSection> => {
112 const counts = new Map<string, number>()
113
114 for (const section of sections) {
115 counts.set(baseOf(section.id), (counts.get(baseOf(section.id)) ?? 0) + 1)
116 }
117
118 return new Map(sections.map(section => [counts.get(baseOf(section.id)) === 1 ? baseOf(section.id) : section.id, section]))
119}
120
121/** Every difference between two fingerprints, in the order the newer one lists them. */
122export const diffPrints = (before: DriftPrint, after: DriftPrint): Drift => {
123 const oldSections = sectionsByKey(before.sections)
124 const newSections = sectionsByKey(after.sections)
125 const oldTools = new Map(before.tools.map(tool => [tool.name, tool]))
126 const newTools = new Map(after.tools.map(tool => [tool.name, tool]))
127 const sectionsChanged: SectionChange[] = []
128 const toolsChanged: ToolChange[] = []
129
130 for (const [key, section] of newSections) {
131 const old = oldSections.get(key)
132
133 if (old !== undefined && old.hash !== section.hash) {
134 const formerId = old.id === section.id ? null : old.id
135 sectionsChanged.push({ id: section.id, formerId, scope: section.scope, before: old.chars, after: section.chars })
136 }
137 }
138
139 for (const tool of after.tools) {
140 const old = oldTools.get(tool.name)
141
142 if (old !== undefined && (old.hash !== tool.hash || old.isDeferred !== tool.isDeferred)) {
143 toolsChanged.push({
144 name: tool.name,
145 before: old.chars,
146 after: tool.chars,
147 isTextChanged: old.hash !== tool.hash,
148 deferral: changeOf(deferralOf(old.isDeferred), deferralOf(tool.isDeferred)),
149 })
150 }
151 }
152
153 return {
154 version: changeOf(before.version, after.version),
155 model: changeOf(before.model, after.model),
156 effort: changeOf(before.effort, after.effort),
157 promptTokens: countOf(before.promptTokens, after.promptTokens),
158 toolTokens: countOf(before.toolTokens, after.toolTokens),
159 sectionsAdded: [...newSections].filter(([key]) => !oldSections.has(key)).map(([, section]) => section),
160 sectionsRemoved: [...oldSections].filter(([key]) => !newSections.has(key)).map(([, section]) => section),
161 sectionsChanged,
162 toolsAdded: after.tools.filter(tool => !oldTools.has(tool.name)),
163 toolsRemoved: before.tools.filter(tool => !newTools.has(tool.name)),
164 toolsChanged,
165 }
166}
167
168/**
169 * The part of a drift that ships with the build: shipped sections, built-in
170 * tools and a prompt estimate that moved past the noise. Session sections are yours.
171 */
172export const shippedPart = (drift: Drift): Drift => ({
173 ...drift,
174 promptTokens:
175 drift.promptTokens !== null && Math.abs(drift.promptTokens.after - drift.promptTokens.before) >= PROMPT_NOISE_TOKENS
176 ? drift.promptTokens
177 : null,
178 toolTokens: null,
179 sectionsAdded: drift.sectionsAdded.filter(section => section.scope === 'shipped'),
180 sectionsRemoved: drift.sectionsRemoved.filter(section => section.scope === 'shipped'),
181 sectionsChanged: drift.sectionsChanged.filter(section => section.scope === 'shipped'),
182})
183
184const sectionCount = (drift: Drift): number =>
185 drift.sectionsAdded.length + drift.sectionsRemoved.length + drift.sectionsChanged.length
186
187const toolCount = (drift: Drift): number => drift.toolsAdded.length + drift.toolsRemoved.length + drift.toolsChanged.length
188
189/** Whether the model was given something different; a version bump alone is not. */
190export const hasDrift = (drift: Drift): boolean =>
191 drift.effort !== null ||
192 drift.model !== null ||
193 drift.promptTokens !== null ||
194 drift.toolTokens !== null ||
195 sectionCount(drift) > 0 ||
196 toolCount(drift) > 0
197
198const sectionDelta = (drift: Drift): number =>
199 drift.sectionsAdded.reduce((sum, section) => sum + section.chars, 0) -
200 drift.sectionsRemoved.reduce((sum, section) => sum + section.chars, 0) +
201 drift.sectionsChanged.reduce((sum, section) => sum + section.after - section.before, 0)
202
203const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? '' : 's'}`
204
205const named = (names: readonly string[], one: string, many: string): string =>
206 names.length <= TOOLS_NAMED ? `${names.join(', ')} ${names.length === 1 ? one : many}` : `${names.length} ${many}`
207
208/** The one line a toast shows, e.g. `drift: 2.1.284 → 2.1.285 · prompt +1.2k chars (2 sections) · Bash description changed`. */
209export const summary = (drift: Drift, after: DriftPrint): string => {
210 const parts = [drift.version === null ? `drift on ${after.version}` : `drift: ${drift.version.before} → ${drift.version.after}`]
211
212 if (drift.model !== null) {
213 parts.push(`model ${drift.model.before} → ${drift.model.after}`)
214 }
215
216 const sections = sectionCount(drift)
217
218 if (sections > 0) {
219 parts.push(`prompt ${signed(sectionDelta(drift))} chars (${plural(sections, 'section')})`)
220 }
221
222 if (drift.promptTokens !== null) {
223 parts.push(`system prompt ${signed(drift.promptTokens.after - drift.promptTokens.before)} tokens`)
224 }
225
226 const rewritten = drift.toolsChanged.filter(tool => tool.isTextChanged).map(tool => tool.name)
227 const moved = drift.toolsChanged.filter(tool => tool.deferral !== null)
228
229 if (rewritten.length > 0) {
230 parts.push(named(rewritten, 'description changed', 'tool descriptions changed'))
231 }
232
233 for (const tool of moved) {
234 parts.push(`${tool.name} now ${tool.deferral?.after ?? ''}`)
235 }
236
237 if (drift.toolsAdded.length > 0) {
238 parts.push(named(drift.toolsAdded.map(tool => tool.name), 'tool added', 'tools added'))
239 }
240
241 if (drift.toolsRemoved.length > 0) {
242 parts.push(named(drift.toolsRemoved.map(tool => tool.name), 'tool removed', 'tools removed'))
243 }
244
245 if (drift.effort !== null) {
246 parts.push(`effort ${drift.effort.before} → ${drift.effort.after}`)
247 }
248
249 return parts.join(' · ')
250}
251
252/** Two prints compare only on one model and one mode: `-p` and the REPL assemble different prompts. */
253export const profileOf = (print: DriftPrint): string => `${print.model}|${print.mode}`
254
255const latest = (prints: readonly DriftPrint[], keep: (print: DriftPrint) => boolean): DriftPrint | null =>
256 prints.reduce<DriftPrint | null>((best, print) => (keep(print) && (best === null || print.at > best.at) ? print : best), null)
257
258const earlier = (history: readonly DriftPrint[], print: DriftPrint): DriftPrint[] =>
259 history.filter(other => other !== print && other.at < print.at)
260
261/** The last earlier session on the same model and mode: what a toast compares with. */
262export const referenceFor = (history: readonly DriftPrint[], print: DriftPrint): DriftPrint | null =>
263 latest(earlier(history, print), other => profileOf(other) === profileOf(print))
264
265/** The last earlier session on another version or model. */
266export const baselineFor = (history: readonly DriftPrint[], print: DriftPrint): DriftPrint | null =>
267 latest(earlier(history, print), other => other.version !== print.version || other.model !== print.model)
268
269export const addPrint = (history: readonly DriftPrint[], print: DriftPrint): DriftPrint[] =>
270 [...history, print].sort((a, b) => a.at - b.at).slice(-MAX_PRINTS)
271
272const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
273
274const isCount = (value: unknown): value is number | null => value === null || typeof value === 'number'
275
276const isSection = (value: unknown): value is DriftSection =>
277 isRecord(value) &&
278 typeof value.id === 'string' &&
279 (value.scope === 'shipped' || value.scope === 'session') &&
280 typeof value.chars === 'number' &&
281 typeof value.hash === 'string'
282
283const isTool = (value: unknown): value is DriftTool =>
284 isRecord(value) &&
285 typeof value.name === 'string' &&
286 typeof value.chars === 'number' &&
287 typeof value.hash === 'string' &&
288 typeof value.isDeferred === 'boolean'
289
290const isPrint = (value: unknown): value is DriftPrint =>
291 isRecord(value) &&
292 typeof value.at === 'number' &&
293 typeof value.sessionId === 'string' &&
294 typeof value.version === 'string' &&
295 typeof value.model === 'string' &&
296 (value.effort === null || typeof value.effort === 'string') &&
297 (value.mode === 'interactive' || value.mode === 'headless') &&
298 isCount(value.promptTokens) &&
299 isCount(value.toolTokens) &&
300 Array.isArray(value.sections) &&
301 value.sections.every(isSection) &&
302 Array.isArray(value.tools) &&
303 value.tools.every(isTool)
304
305/** The store's value as fingerprints, oldest first; anything malformed (an older format, a hand edit) is dropped. */
306export const parseStored = (value: unknown): DriftPrint[] =>
307 Array.isArray(value) ? value.filter(isPrint).sort((a, b) => a.at - b.at) : []
308
309export const sectionChars = (print: DriftPrint): number => print.sections.reduce((sum, section) => sum + section.chars, 0)
310
311/** `2026-10-06 12:00 UTC`: one zone for every row, so two machines' histories read alike. */
312export const when = (at: number): string => {
313 const iso = new Date(at).toISOString()
314
315 return `${iso.slice(0, DATE_END)} ${iso.slice(DATE_END + 1, MINUTE_END)} UTC`
316}
317
318const printLabel = (print: DriftPrint): string =>
319 `${when(print.at)} · ${print.version} · ${print.model} · ${print.mode} · effort ${print.effort ?? 'none'}`
320
321const tokens = (count: number | null): string => (count === null ? '?' : `~${compactChars(count)}`)
322
323const sizeLine = (print: DriftPrint): string =>
324 `system prompt ${tokens(print.promptTokens)} tokens, of it ${plural(print.sections.length, 'section')} hooks can see (${compactChars(sectionChars(print))} chars) · ${plural(print.tools.length, 'built-in tool')} ${tokens(print.toolTokens)} tokens`
325
326const yours = (scope: DriftScope): string => (scope === 'session' ? ' (yours)' : '')
327
328const toolLine = (tool: ToolChange): string => {
329 const parts: string[] = []
330
331 if (tool.isTextChanged) {
332 parts.push(`description ${compactChars(tool.before)} → ${compactChars(tool.after)} chars (${signed(tool.after - tool.before)})`)
333 }
334
335 if (tool.deferral !== null) {
336 parts.push(`${tool.deferral.before} → ${tool.deferral.after}`)
337 }
338
339 return ` ~ tool ${tool.name}: ${parts.join(', ')}`
340}
341
342/** The lines of one drift, indented under its heading; empty when nothing differs. */
343export const driftLines = (drift: Drift): string[] => {
344 const lines: string[] = []
345
346 for (const [label, change] of [['version', drift.version], ['model', drift.model], ['effort', drift.effort]] as const) {
347 if (change !== null) {
348 lines.push(` ${label} ${change.before} → ${change.after}`)
349 }
350 }
351
352 for (const [label, count] of [['system prompt', drift.promptTokens], ['built-in tools', drift.toolTokens]] as const) {
353 if (count !== null) {
354 lines.push(` ${label} ~${compactChars(count.before)} → ~${compactChars(count.after)} tokens (${signed(count.after - count.before)})`)
355 }
356 }
357
358 for (const section of drift.sectionsChanged) {
359 const was = section.formerId === null ? '' : ` (was ${section.formerId})`
360 lines.push(` ~ section ${section.id}${was}: ${compactChars(section.before)} → ${compactChars(section.after)} chars (${signed(section.after - section.before)})${yours(section.scope)}`)
361 }
362
363 for (const section of drift.sectionsAdded) {
364 lines.push(` + section ${section.id}: ${signed(section.chars)} chars${yours(section.scope)}`)
365 }
366
367 for (const section of drift.sectionsRemoved) {
368 lines.push(` - section ${section.id}: ${signed(-section.chars)} chars${yours(section.scope)}`)
369 }
370
371 lines.push(...drift.toolsChanged.map(toolLine))
372
373 for (const tool of drift.toolsAdded) {
374 lines.push(` + tool ${tool.name}: ${signed(tool.chars)} chars${tool.isDeferred ? ', deferred' : ''}`)
375 }
376
377 for (const tool of drift.toolsRemoved) {
378 lines.push(` - tool ${tool.name}: ${signed(-tool.chars)} chars`)
379 }
380
381 return lines
382}
383
384const block = (heading: string, before: DriftPrint, after: DriftPrint): string[] => {
385 const lines = driftLines(diffPrints(before, after))
386
387 return [`${heading} (${printLabel(before)}):`, ...(lines.length === 0 ? [' nothing changed'] : lines)]
388}
389
390export const NO_PRINTS = 'No fingerprint yet: drift-radar takes one after the first answer of a session.'
391
392/** What `/drift` prints: the current fingerprint against the last comparable session and the last other version or model. */
393export const report = (current: DriftPrint | null, history: readonly DriftPrint[], isThisSession: boolean): string => {
394 if (current === null) {
395 return NO_PRINTS
396 }
397
398 const lines = [`${isThisSession ? 'This session' : 'Latest session'}: ${printLabel(current)}`, sizeLine(current)]
399 const reference = referenceFor(history, current)
400 const baseline = baselineFor(history, current)
401
402 if (reference === null && baseline === null) {
403 lines.push('', 'Nothing earlier to compare with yet. The next session or the next update will have a diff.')
404
405 return lines.join('\n')
406 }
407
408 if (reference !== null) {
409 lines.push('', ...block('Since the last session on this model and mode', reference, current))
410 }
411
412 if (baseline !== null && baseline !== reference) {
413 lines.push('', ...block('Since the last other version or model', baseline, current))
414 }
415
416 lines.push('', '(yours) = a section built from your files, folder, date or setup: shown here, never toasted.')
417
418 return lines.join('\n')
419}
420
421/** What `/drift history` prints: one row per stored fingerprint, newest first. */
422export const historyText = (history: readonly DriftPrint[], sessionId: string): string => {
423 if (history.length === 0) {
424 return NO_PRINTS
425 }
426
427 const rows = [...history].reverse().map(print => {
428 const mark = print.sessionId === sessionId ? '*' : ' '
429
430 return `${mark} ${when(print.at)} ${print.version} ${print.model} ${print.mode} effort ${print.effort ?? 'none'} prompt ${tokens(print.promptTokens)} tokens ${plural(print.tools.length, 'tool')}`
431 })
432
433 return [`${plural(history.length, 'fingerprint')} stored (max ${MAX_PRINTS}), newest first; * = this session`, ...rows].join('\n')
434}
435
436const unsafe = /[^A-Za-z0-9._-]+/g
437
438/** `2.1.285-claude-opus-5-5-2026-10-06.md`: a file name per version, model and day. */
439export const dumpName = (version: string, model: string, at: number): string =>
440 `${version.replace(unsafe, '_')}-${model.replace(unsafe, '_')}-${new Date(at).toISOString().slice(0, DATE_END)}.md`
441
442/** The folder dumps go to: the setting (with `~/` expanded) or `~/.claude/drift-radar`; null when HOME is needed and unset. */
443export const dumpFolder = (setting: string, home: string | undefined): string | null => {
444 const chosen = setting.trim().replace(/\/+$/, '')
445
446 if (chosen === '') {
447 return home === undefined ? null : `${home}/.claude/drift-radar`
448 }
449
450 if (chosen.startsWith('~/')) {
451 return home === undefined ? null : `${home}/${chosen.slice(2)}`
452 }
453
454 return chosen
455}
456
457export type DumpInput = {
458 version: string
459 model: string
460 at: number
461 sections: readonly DriftSectionText[]
462 tools: readonly DriftToolText[]
463}
464
465/**
466 * The shipped sections and the built-in tools' descriptions in full, as Markdown for `git diff`.
467 * Session sections are listed by size only: they hold your CLAUDE.md and paths.
468 */
469export const dumpText = (input: DumpInput): string => {
470 const lines = [`# Claude Code ${input.version} · ${input.model} · ${when(input.at)}`, '', '## System prompt sections', '']
471
472 for (const section of [...input.sections].sort((a, b) => a.id.localeCompare(b.id))) {
473 if (scopeOf(section.id) === 'shipped') {
474 lines.push(`### ${section.id} (${section.text.length} chars)`, '', section.text, '')
475 } else {
476 lines.push(`### ${section.id} (${section.text.length} chars, yours: text left out)`, '')
477 }
478 }
479
480 lines.push('## Built-in tools', '')
481
482 for (const tool of [...input.tools].sort((a, b) => a.name.localeCompare(b.name))) {
483 lines.push(`### ${tool.name} (${tool.description.length} chars${tool.isDeferred ? ', deferred' : ''})`, '', tool.description, '')
484 }
485
486 return lines.join('\n')
487}
488
489/** Picks the /context rows a fingerprint keeps; the rows carry no id, so they are found by label. */
490export const categoryTokens = (categories: readonly { name: string; tokens: number }[], name: string): number | null =>
491 categories.find(category => category.name === name)?.tokens ?? null
492
493export const modeOf = (isInteractive: boolean): DriftMode => (isInteractive ? 'interactive' : 'headless')
494
495export const USAGE = [
496 '/drift what changed in the system prompt, tools, model and effort',
497 '/drift history the stored fingerprints',
498 '/drift dump write the shipped prompt sections and built-in tool descriptions to a file',
499].join('\n')
500types/index.d.ts 44 lines1/** `shipped` sections read the same for everyone on a build and model; `session` ones carry your files, folder and date. */
2export type DriftScope = 'shipped' | 'session'
3
4export type DriftMode = 'interactive' | 'headless'
5
6export type DriftSection = { id: string; scope: DriftScope; chars: number; hash: string }
7
8export type DriftTool = { name: string; chars: number; hash: string; isDeferred: boolean }
9
10/** What the model was given in one session, reduced to sizes and short hashes. */
11export type DriftPrint = {
12 at: number
13 sessionId: string
14 version: string
15 model: string
16 effort: string | null
17 mode: DriftMode
18 /** The whole system prompt as /context estimates it, the parts no hook sees included. */
19 promptTokens: number | null
20 toolTokens: number | null
21 sections: DriftSection[]
22 tools: DriftTool[]
23}
24
25/** A system prompt section's text as the engine assembled it this session. */
26export type DriftSectionText = { id: string; text: string }
27
28/** A built-in tool's description as the engine rendered it this session. */
29export type DriftToolText = { name: string; description: string; isDeferred: boolean }
30
31export type DriftStep = { model: string; effort: string | null }
32
33declare module 'claude-code' {
34 interface PluginState {
35 'drift-radar': {
36 sections: DriftSectionText[]
37 tools: DriftToolText[]
38 lastStep: DriftStep | null
39 printedModel: string | null
40 mode: DriftMode
41 }
42 }
43}
44