Keeps the session's goal in view: tracks topic drift from prompts, shows a focus band, and re-anchors the model on the goal.

🇬🇧 English · 🇫🇷 Français
Keep long Claude Code sessions on track. task-anchor is a mod that remembers what the session is for, notices when the conversation drifts away from it, and nudges both you and the model back.
In a long session, each request pulls the context a little further from where you started. Nothing breaks suddenly: the work just slowly stops serving the original goal. task-anchor makes that drift visible early and applies a counterweight.
The idea is inspired by Johnson & Huo, Competition for attention predicts good-to-bad tipping in AI (Patterns, 2026): accumulated context steers what comes next, and injecting the right content in the context delays the tipping. A mod cannot see a model's hidden states, so this is not an implementation of their formula, only the same intuition applied with text signals.
/anchor <goal>.In a terminal Claude Code session:
/plugin install task-anchor --marketplace pquattro/task-anchor
Answer y to add the marketplace, then pick a scope.
To try it from a local copy instead:
claude --plugin-dir /path/to/task-anchor
| Command | Effect |
|---|---|
/anchor <goal> | Set the goal and reset focus |
/anchor | Show the current goal |
/anchor clear | Clear the goal (the next substantial prompt becomes the new one) |
Drift is normal when you change topic on purpose. Run /anchor <new goal> and tracking restarts.
prompt.compose hooks for installed plugins.claude plugin validate .
claude plugin test .
The tests live in hooks/register.test.ts. The mod is a single hooks module, hooks/register.tsx.
hooks/register.tsx 168 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const goal = atom({ plugin: 'task-anchor', key: 'goal' } as const, null)
5const focus = atom({ plugin: 'task-anchor', key: 'focus' } as const, 1)
6const offStreak = atom({ plugin: 'task-anchor', key: 'offStreak' } as const, 0)
7const isHidden = atom({ plugin: 'task-anchor', key: 'isHidden' } as const, false)
8
9const DRIFT_BELOW = 0.3
10const MIN_WORDS = 3
11const MIN_GOAL_WORDS = 4 // a casual line ("on est bon") is not a goal
12const STOP = new Set(
13 ('le la les un une des de du et ou en au aux ce ces cet cette que qui quoi dont pour par sur dans avec sans est sont ' +
14 'etre avoir fait faire peux peut veux vas ton ta tes mon ma mes son sa ses nous vous ils elles pas plus mais donc ' +
15 'the a an and or of to in on for with is are be do does can you your my it this that these those not but so ' +
16 'please merci stp bon ok oui non alors aussi comme tout tous toute very just').split(' '),
17)
18
19// Crude lexical proxy for "is this prompt about the goal": keyword sets, accents folded,
20// plural/suffix trimmed. Real semantic distance would need embeddings the mod cannot reach.
21const keywords = (text: string): Set<string> =>
22 new Set(
23 text
24 .toLowerCase()
25 .normalize('NFD')
26 .replace(/[̀-ͯ]/g, '')
27 .split(/[^a-z0-9_.\/-]+/)
28 .filter(w => w.length > 2 && !STOP.has(w))
29 .map(w => w.replace(/(s|x)$/, '')),
30 )
31
32// Levenshtein distance, abandoned as soon as it must exceed `max`.
33const within = (a: string, b: string, max: number): boolean => {
34 if (Math.abs(a.length - b.length) > max) return false
35 let prev = Array.from({ length: b.length + 1 }, (_, j) => j)
36 for (let i = 1; i <= a.length; i++) {
37 const cur = [i]
38 let low = i
39 for (let j = 1; j <= b.length; j++) {
40 const v = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
41 cur.push(v)
42 if (v < low) low = v
43 }
44 if (low > max) return false
45 prev = cur
46 }
47 return prev[b.length] <= max
48}
49
50// Typos: one edit from 5 letters up, two from 9, none for short words (too many false hits).
51const similar = (w: string, set: Set<string>): boolean => {
52 if (set.has(w)) return true
53 const max = w.length >= 9 ? 2 : w.length >= 5 ? 1 : 0
54 if (max === 0) return false
55 for (const o of set) if (o[0] === w[0] && within(w, o, max)) return true
56 return false
57}
58
59const overlap = (a: Set<string>, b: Set<string>): number => {
60 if (a.size === 0 || b.size === 0) return 0
61 let hits = 0
62 for (const w of a) if (similar(w, b)) hits += 1
63 return hits / Math.min(a.size, b.size)
64}
65
66const bar = (v: number): string => {
67 const n = Math.round(Math.max(0, Math.min(1, v)) * 5)
68 return '●'.repeat(n) + '○'.repeat(5 - n)
69}
70
71// Words of the goal plus words of prompts judged on-topic, so the topic can evolve
72// slowly inside the goal's scope instead of being frozen to its first sentence.
73let topic = new Set<string>()
74let goalWords = new Set<string>()
75
76async function setGoal($: EngineInterface, text: string) {
77 goalWords = keywords(text)
78 topic = new Set(goalWords)
79 await update($, goal, () => text)
80 await update($, focus, () => 1)
81 await update($, offStreak, () => 0)
82}
83
84export const register: Register = on => {
85 on('session.start', async ($, e, next) => {
86 await $.command.register({
87 name: 'anchor',
88 description: 'Set, show or clear the session goal (/anchor <goal> | /anchor clear)',
89 })
90 return next(e)
91 })
92
93 on('command.run', { command: 'anchor' }, async ($, e) => {
94 const arg = e.args.trim()
95 if (arg === 'clear') {
96 await update($, goal, () => null)
97 return { text: 'goal cleared.' }
98 }
99 if (arg === '') {
100 const g = await read($, goal)
101 return { text: g === null ? 'no goal yet. /anchor <goal>' : `goal: ${g}` }
102 }
103 await setGoal($, arg)
104 return { text: 'goal set. I will watch for drift.' }
105 })
106
107 on('prompt.submit', async ($, e, next) => {
108 if (e.command || e.isMeta) return next(e)
109
110 const g = await read($, goal)
111 if (g === null) {
112 // the first substantial prompt defines the session's goal; chit-chat before it is ignored
113 if (keywords(e.text).size >= MIN_GOAL_WORDS) await setGoal($, e.text.slice(0, 300))
114 return next(e)
115 }
116
117 const words = keywords(e.text)
118 if (words.size < MIN_WORDS) return next(e) // "oui", "ok vas-y": neutral, no signal
119
120 const score = Math.max(overlap(words, goalWords), overlap(words, topic))
121 const prev = await read($, focus)
122 const next_ = 0.6 * prev + 0.4 * Math.min(1, score * 1.5)
123 await update($, focus, () => next_)
124
125 if (score >= 0.3) {
126 for (const w of words) topic.add(w)
127 await update($, offStreak, () => 0)
128 } else {
129 const n = (await read($, offStreak)) + 1
130 await update($, offStreak, () => n)
131 if (next_ < DRIFT_BELOW && n >= 2) {
132 $.ui.toast(`Dérive: ${n} prompts hors du but. /anchor pour redéfinir ou continuer.`)
133 }
134 }
135 return next(e)
136 })
137
138 // Counterweight: the goal is restated in the system prompt, sharper once drift is flagged.
139 on('prompt.compose', async ($, e, next) => {
140 const res = await next(e)
141 const g = await read($, goal)
142 if (g === null) return res
143
144 const drifting = (await read($, focus)) < DRIFT_BELOW
145 const text = drifting
146 ? `Session goal: "${g}". The conversation appears to have drifted from it. Before continuing, state briefly how the current request relates to the goal, or ask the user whether the goal has changed.`
147 : `Session goal: "${g}". Keep work aligned with it; flag it if a request leaves its scope.`
148 return { sections: [...res.sections, { id: 'task-anchor:goal', text, scope: 'session' as const }] }
149 })
150
151 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
152 const g = await read($, goal)
153 if (e.props.hasSurvey || g === null || (await read($, isHidden))) return next(e)
154
155 const f = await read($, focus)
156 const { Box, Button, Text } = $.ui.resolve(e)
157 return (
158 <Box>
159 <Text dimColor={f >= DRIFT_BELOW} color={f < DRIFT_BELOW ? 'yellow' : undefined}>
160 But: {g.slice(0, 60)}
161 {g.length > 60 ? '…' : ''} · focus {bar(f)}{' '}
162 </Text>
163 <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
164 </Box>
165 )
166 })
167}
168types/index.d.ts 13 lines1export type Focus = number
2
3declare module 'claude-code' {
4 interface PluginState {
5 'task-anchor': {
6 goal: string | null
7 focus: Focus
8 offStreak: number
9 isHidden: boolean
10 }
11 }
12}
13