SLOPSHOPPER

drift-radar

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

newcommandtoastprompt
v0.1.0MITupdated 2026-10-06Jvrd97/claude-drift-radar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · drift-radar
› 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 › /drift ⎿ drift-radar: No fingerprint yet: drift-radar takes one after the first answer of a session. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

drift-radar

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

Why

"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.

What it does

WhereWhat
ToastOne 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
/driftThe 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 historyThe stored fingerprints: date, version, model, mode, effort, prompt size, tool count
/drift dumpWrites 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.

Install

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

Settings

Change them in /config under the plugin, or in settings.json under pluginConfigs:

FieldDefaultMeaning
toasttrueShow the one-line toast on drift. Off still stores fingerprints for /drift
dumpDiremptyWhere /drift dump writes; empty means ~/.claude/drift-radar, ~/ is expanded

How it works

  • 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.
  • At the end of the first answered turn (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.
  • Fingerprints go to the plugin's own store (~/.claude/plugins/store/drift-radar…json), the last 30. Text is not stored, only sizes and hashes.
  • The toast compares with the last session on the same model and mode, because -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.
  • Every hook passes its event through untouched. The mod sends nothing over the network and writes a file only on /drift dump.

What it can't do:

  • See the whole prompt text. In 2.1.285 hooks get the named sections (about 5k characters), not the static core (intro, tone, how to do tasks). The core shows up only as the /context token estimate; a move under 100 tokens is treated as noise and not toasted.
  • See tool input schemas. Plugins get descriptions and the deferred flag, not schemas.
  • See server-side changes: model weights, the thinking budget the API applies, A/B tests. It sees what the client sends.
  • Tell a shipped change from a change of your own setup when a built-in tool's text depends on it (custom agents, skills, login state). The section list above is a name heuristic; a misjudged section can toast once or be missed.
  • Guarantee every fingerprint: two sessions that finish at the same instant can overwrite each other's store write.

Develop

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.

  • После первого ответа в сессии отпечаток сохраняется: длины и короткие хэши, без текста.
  • Если промпт, инструмент или effort изменились по сравнению с прошлой сессией на той же модели, приходит одно уведомление. Если ничего не изменилось, мод молчит.
  • /drift показывает полный дифф, /drift history — историю, /drift dump пишет текст промпта в файл для git diff.

Зачем: жалобы «Claude поглупел» иногда оказываются правдой, а changelog пишет не всё. Мод ничего не отправляет в сеть.

License

MIT

Source 3 files
hooks/register.ts 194 lines
1import { 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}
194
hooks/logic.ts 500 lines
1import 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')
500
types/index.d.ts 44 lines
1/** `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