SLOPSHOPPER

task-anchor

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

bandcommandtoastprompt
v0.1.0MITupdated 2026-10-17pquattro/task-anchor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-anchor
› 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 › /anchor ⎿ task-anchor: goal: fix the failing auth test and add an audit log call But: fix the failing auth test and add an audit log call · focus ●●●●● [ Hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
But: fix the failing auth test and add an audit log call · focus ●●●●● [ Hide ]
README

task-anchor

🇬🇧 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.

Why

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.

What it does

  • Remembers the goal. The first substantial prompt of the session (at least 4 meaningful words) becomes the goal. Casual lines such as "ok" or "on est bon" are ignored. Set or change it any time with /anchor <goal>.
  • Scores drift. Each prompt is compared to the goal and to the topics already accepted as on-topic, by keyword overlap. Accents, plurals and typos are tolerated. Very short replies ("ok", "oui, vas-y") are neutral. The score is smoothed, so one stray question does not trigger an alarm.
  • Shows it. A band above the prompt displays the goal and a 5-dot focus gauge, in yellow when focus is low. A Hide button dismisses it.
  • Warns. After repeated off-topic prompts with low focus, a toast reminds you to redefine the goal or carry on.
  • Re-anchors the model. The goal is added to the system prompt. Once drift is flagged, the model is asked to say how the current request relates to the goal, or to check whether the goal has changed.

Install

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

Usage

CommandEffect
/anchor <goal>Set the goal and reset focus
/anchorShow the current goal
/anchor clearClear 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.

Limits

  • The score is a lexical proxy, not a semantic one. A relevant prompt worded with entirely different vocabulary can look like drift, and a change of topic that reuses the goal's words goes unnoticed.
  • Typo tolerance needs the same first letter and, for words under 5 letters, an exact match.
  • The system prompt addition is not applied where an organization manages Claude Code and restricts prompt.compose hooks for installed plugins.
  • State is kept per session, not across sessions.

Development

claude plugin validate .
claude plugin test .

The tests live in hooks/register.test.ts. The mod is a single hooks module, hooks/register.tsx.

License

MIT

Source 2 files
hooks/register.tsx 168 lines
1import { 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}
168
types/index.d.ts 13 lines
1export 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