SLOPSHOPPER

deviation-kernel

Holds a project's refused assumptions in Claude's system prompt, flags edits and answers that re-impose them, and pauses on the step where it happens

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · deviation-kernel
│ ┃ deviation-kernel ✕ › fix the failing auth test and add an audit log call │ ┃ Mode: ask · file: `` │ ┃ ⏺ 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 │ │ › /kernel │ ⎿ deviation-kernel: No KERNEL.md in this project. Run /kernel init │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · deviation-kernel
Mode: ask · file: ``
README

Claude Code Mods

Part of my portfolio.

Mods for Claude Code that change what it does and what it shows. Each one is a hooks module: it runs inside Claude Code, hooks prompts, tool calls and turns, and draws a line above the prompt or a pane beside the transcript. The five here are deviation-kernel, cache-meter, goal-board, file-tree and idea-agents. All five ship with tests that run without a live session.

ModWhat it doesCosts anything?
deviation-kernelKeeps the assumptions your project refuses in Claude's system prompt on every request, and catches an edit that brings one back at the step where it lands.One small Haiku call after each long answer (can be switched off)
cache-meterShows how long the prompt cache stays warm, what a miss would cost to rewrite, how full the context is, and the session's cost at API rates.No
goal-boardShows each session's goal, task progress and ETA, and /goals lists every running session on the machine.No
file-treeShows the working directory as a file tree in a pane on the right, with folders you open and close and the files Claude just changed marked.No
idea-agentsGives a project six exploration agents (three that explore ideas, a lazy "trogg" for each) and one ideas/ folder per run, and tells Claude how to run a round.Only when you run a round: the agents' own model use (the troggs run on Haiku)

Install any one of them on its own.

Set it up with Claude

Paste this into Claude Code, in a terminal session:

Set up mods from the GitHub repo Spendry/claude-code-mods for me. Work through these steps and stop where a step says to ask me.

1. Run `claude --version`. The mods need 2.1.287 or later; if it's older, tell me how to update and stop.
2. Show me the table of mods from the repo README (https://github.com/Spendry/claude-code-mods) and ask which ones I want. Don't pick for me.
3. Clone https://github.com/Spendry/claude-code-mods into a temporary folder. For each mod I chose, run `claude plugin validate <that folder>/<mod>`, read its `hooks/register.ts`, and tell me in plain words what it can touch (the `calls:` line lists it). Ask me whether to go ahead.
4. Run `claude plugin marketplace add Spendry/claude-code-mods`, then install each mod I approved with `claude plugin install <mod>@claude-code-mods --scope user`. A note that options are not yet set is expected; the defaults apply until I change them. Delete the temporary clone.
5. Run `claude plugin list` and confirm each one shows as enabled.
6. If I chose deviation-kernel: ask which project it's for, and interview me there for the assumptions that project refuses on purpose. Do not suggest refusals of your own; the point of the kernel is that they come from me. For each one I give you, help me word it, propose tells (words or /regex/ that would show up when it's broken) and an "instead:", then show me the KERNEL.md and write it to the project root only when I say so. Then ask which mode I want (ask, warn or block), explain the difference in one line each, and tell me to type `/kernel mode <mode>` in that project's session to set it.
7. If I chose cache-meter: ask whether I pay API rates or use a subscription, and explain that on a subscription its dollar figures are the API-equivalent, not my bill.
8. Finish with a short list of what I still have to do myself (from the "What only you can do" section of the README).

Claude runs the install. It can't decide your project's refusals or see the bands for you; that part stays yours.

What only you can do

  • Pick the mods. Each one changes how sessions behave. Read its section below before turning it on.
  • Decide what your project refuses. deviation-kernel is only as good as its KERNEL.md. Claude can help you word a refusal and suggest tells, but which assumptions to refuse is your design decision. Start with two or three. You can add more later with /kernel add.
  • Exempt your idea notes. Notes that weigh a refused assumption have to name it. List their folders under ## Exempt in KERNEL.md (for example - ideas/**) so the guard skips them; code everywhere else stays checked.
  • Check the tells after a few days. A tell that fires on harmless text wastes your attention. A refusal with no tell at all goes unguarded at the edit step. /kernel log shows what tripped and what you decided.
  • Look at it in a terminal. The bands and panes draw in a terminal session and in the Claude desktop app's Code tab. In the VS Code extension's chat panel the hooks still run (the kernel still guards edits) but nothing draws above the prompt, and /goals and /cache answer in text instead.
  • Keep cache-meter's prices current. They are typed into cache-meter/hooks/register.ts with the date they were checked. A model not in the table is priced at the first row's rates and shown with ≈.

Install by hand

At the prompt of a Claude Code terminal session, one line per mod:

/plugin install deviation-kernel --marketplace Spendry/claude-code-mods
/plugin install cache-meter --marketplace Spendry/claude-code-mods
/plugin install goal-board --marketplace Spendry/claude-code-mods
/plugin install file-tree --marketplace Spendry/claude-code-mods
/plugin install idea-agents --marketplace Spendry/claude-code-mods

Answer y to add the marketplace, then pick the user scope so the mod loads in every session. A mod with options shows a screen to set them. /plugin lists what's active.

To turn every mod off for one session: claude --safe-mode. To update: claude plugin update <mod>@claude-code-mods, then /reload-plugins.

Built and tested against Claude Code 2.1.296; needs 2.1.287 or later.


deviation-kernel

What it guarantees: the project's refused assumptions are in Claude's system prompt on every request, with nothing to paste and nothing lost to compaction. An edit that brings one back is caught at that step.

The problem it answers: a design that refuses a standard assumption tends to lose that refusal while the ordinary parts get filled in. Claude reaches for the conventional component in each slot, and the conventional component carries the assumption the design rejected.

Setup. Put a KERNEL.md at the project root (or .claude/KERNEL.md). /kernel init writes a template; KERNEL.example.md shows a filled-in one. Each refusal is one line:

- R1: one global clock paces every stage | tells: global clock, clock tree, /clk_?en/ | instead: each stage hands off by local request/acknowledge

In a folder with no kernel, the first prompt you type there asks whether Claude should help you write one: now, after that message, not this session, or never in that folder. Mid-project, Claude reads the project's own record first so its questions point at what you set out to do, then asks you for the refusals (it proposes none of its own) and writes KERNEL.md only when you say so. Your home folder is never asked. Turn the offer off with the offerKernel option.

What happens.

  • The kernel goes into the system prompt, along with instructions to check each filler against each refusal and to take the conventional answer wherever the kernel isn't touched.
  • When an Edit or Write brings in a tell that wasn't already in the text it replaces, the mod acts by mode:
  • ask (default): holds the edit and asks you. Refusing sends Claude the refusal and its instead:.
  • warn: lets it through and flags it to Claude with your next prompt.
  • block: refuses the edit outright.
  • After each answer over 300 characters, a Haiku call reads it against the kernel for drift the tells miss, such as a standard component that carries a refused assumption without naming it. Hits show in the band and go to Claude with your next prompt. Press drop in the band if a flag is wrong. Turn this off with the semanticCheck option if you don't want the extra calls.

Commands. /kernel opens the kernel and recent trips · /kernel init · /kernel add <text> | tells: a, b | instead: ... · /kernel mode ask|warn|block · /kernel check (a full audit of the conversation so far, served mostly from cache) · /kernel log · /kernel reload.

Limits. Tells are matched as whole words or regexes, so a tell also fires on a line that rejects the assumption ("no central scheduler"). The semantic check tells the two apart; the keyword check does not. The kernel file is never edited by Claude unless you ask.

cache-meter

What it guarantees: you can see how long the cached prefix has left, what a miss would cost to rewrite, context fill and session cost, without asking.

cache ▰▰▰▰▱▱▱▱ 2:41 · miss $0.50 · ctx 84k/200k 42% · session $4.10 API
  • A toast one minute before the cache goes cold while you're idle.
  • /cache gives the full breakdown, including the hit and miss price of the next request.
  • Cache lifetime. The cacheTtl option defaults to auto. It assumes the 5-minute cache until a request reads from cache after more than five idle minutes, which only the 1-hour cache can serve. It then switches to 1h, toasts once, and remembers that for later sessions. The rewrite price changes with it (1.25x input for 5m, 2x for 1h). Set 5m or 1h to fix it.
  • Prices are API list prices in a table at the top of hooks/register.ts. On a subscription the dollar figures are what the same work would cost at API rates, not what you're billed.

goal-board

What it guarantees: each session shows its goal, task progress and ETA, and one command shows every running session on this machine.

  • The goal is your first prompt (its first line, shortened) until you set one with /goal <text>. /goal clear resets it.
  • Progress comes from Claude's own task tools (TaskCreate and TaskUpdate, or TodoWrite on older builds).
  • ETA = average time per finished task × tasks left. It appears once the first task completes.
  • /goals opens the board of all sessions: working, idle or done, the current task, and the ETA.
  • Sessions share one store on your machine, and each writes only its own record. A session that ends with all its tasks done stays on the board as done. Any other session drops off when it ends. Every record is cleared after 12 hours idle. Nothing leaves your machine.

file-tree

What it guarantees: you can see the project's files while Claude works on them, and which ones it has changed this session.

╭──────────────────────────────────────────╮
│ work     ‹ up  home  refresh  collapse   │
│ ──────────────────────────────────────── │
│ ├─ ▸ notes                               │
│ └─ ▾ relay · here                        │
│    ├─ ▾ src                              │
│    │  ├─ ● main.ts                       │
│    │  └─   util.ts                       │
│    └─   README.md                        │
│                                          │
│ 1 changed · 1 up from relay              │
╰──────────────────────────────────────────╯
  • The pane opens beside the transcript when the session starts, in the fullscreen layout on a terminal 144 columns or wider. Below that, or on the main screen, /files opens it (above the prompt on the main screen). /files again closes it.
  • Click a folder, or Tab to it and press Enter, to open or close it. With the pane focused, r refreshes and c collapses every folder.
  • ‹ up (or u) moves the tree's root up one folder per press, like cd .., until it reaches the drive or filesystem root. The working folder stays open under its parent and is marked · here. home (or h) puts the root back on the working folder. Only the view moves; Claude's working directory stays where it was.
  • It's kept dark so it stays behind the conversation: a muted orange rounded border on a darker background, every name dimmed, and only changed files in (dim) green.
  • Options. background is the hex color painted behind the tree; the default #0f0f0f is half of Windows Terminal's Dark+ background. Set it to half of your own terminal's background, or leave it empty to keep the terminal's. coverFrame (on by default) draws the orange border over Claude Code's own square pane frame, so the side facing the conversation is orange; Claude Code still shows its thin grey line along the top. Turn it off to draw the border just inside the frame instead.
  • Long names and paths are cut to fit the pane with …: a file or folder name keeps its start, the path in the footer keeps its end.
  • A file Claude wrote or edited this session shows a green ●. A folder holding one has its name in green, so a change inside a closed folder still shows.
  • The tree refreshes after every Write, Edit, NotebookEdit, Bash or PowerShell call. A change made outside Claude shows on the next refresh.
  • The duel. At the bottom of the pane, above the footer, two small stick figures fence while Claude is working and meditate while it waits. The animation is a set of short clips (approach, lunge, exchange, feint, a win for either side; breathing and an aura while seated; disengage, bow and kneel, and rise between the two). Each clip ends on the resting frame of its stance, so the next one, picked at random from those that start there, joins on without a jump. It repaints only its own cells four times a second and hides when the pane is too narrow. Turn it off with the duel option.
  • .git, node_modules, __pycache__, .venv, venv and .next are left out. A tree is cut at 1,500 rows; close a folder to see the rest.

idea-agents

What it guarantees: a project can get a second set of eyes on a stuck problem, from agents that propose and size ideas but never touch its code, with every run kept in its own folder you can read later.

  • The six. premise-breaker lists the assumptions under a problem and flips each one. field-crosser brings mechanisms from other fields, at least half outside computing. idea-sizer checks the ideas against numbers the project already measured and gives each a kill test. Each has a lazy helper on Haiku, a "trogg": Skip asks "why do it at all?", Borrow "what already does this?", Poke "what's the dumbest quick try?". The upper agents read their trogg's note and say in one line what they took.
  • The offer. On your first typed prompt in a project without them, it asks once whether to install them into .claude/agents/. It never overwrites an agent the project already has. Then it asks whether Claude should tailor them to the project (its building blocks, gates, results files); Claude shows each change and writes it only when you say so. Home folders and folders you said never for are skipped.
  • One folder per run. /ideas new <topic> makes ideas/<date>-<nn>-<topic>/ (or under research/ideas/ if the project keeps research apart, or .claude/agents/ideas/ if that folder exists, to keep runs beside the agents) with a run card (README.md: question, read order, outcome, decisions) and 0-data.md to fill. The agents write 1-premises.md, 2-fields.md, 3-sizing.md and their lazy notes next to it; the main session writes 4-result.md.
  • The guide. Where the agents are installed, Claude carries a short guide for running a round: who runs when, that the main session runs the troggs (subagents can't start subagents), that a gate's bar never moves, and that held-out data is scored once.
  • /ideas shows what's installed and the latest run. /ideas install adds missing agents. /ideas tailor starts the tailoring.
  • New agent files load as agent types at the next session start. Until then Claude runs them through the general-purpose agent.

Working on the mods

Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.ts (or .tsx for a mod that draws a pane) and tests/. The marketplace file at .claude-plugin/marketplace.json lists them.

  • claude plugin validate <mod folder> reports what the mod hooks and calls, and anything the engine would refuse.
  • claude plugin test <mod folder> runs its tests without a session.
  • To work on a clone, add the clone as a marketplace (claude plugin marketplace add <path to clone>) and install from it. A folder marketplace is read from the folder itself, so after an edit /reload-plugins picks up the change with no reinstall.

A new mod is a new folder plus one entry in marketplace.json.

License

MIT. See LICENSE.

Source 2 files
hooks/register.ts 583 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Flag, Mode, Refusal } from '../types'
5
6// The kernel lives at the project root, or under .claude/ if you'd rather keep the root clean.
7const KERNEL_PATHS = ['KERNEL.md', '.claude/KERNEL.md']
8const PANE = 'deviation-kernel'
9
10const pending = atom({ plugin: 'deviation-kernel', key: 'pending' } as const, [] as Flag[])
11const summary = atom({ plugin: 'deviation-kernel', key: 'summary' } as const, null)
12
13const TEMPLATE = `# Kernel: <project name>
14
15What this project refuses on purpose. Everything not listed here is allowed to be conventional.
16
17## Refuses
18- R1: <an assumption the standard approach makes that this design rejects> | tells: <word>, <phrase>, /<regex>/ | instead: <what this design does in its place>
19- R2: <another refused assumption> | tells: <word> | instead: <...>
20
21## Keeps conventional
22- <parts you are happy to build the normal way: build system, logging, test harness, file formats>
23
24## Exempt
25- <optional: paths where notes name refused assumptions to weigh them, e.g. ideas/**; edits there skip the tell check>
26`
27
28// ---------- parsing ----------
29
30// exempt: paths where notes may name refused assumptions in order to weigh them
31// (idea notes, design reviews). Edits there skip the tell check.
32type Parsed = { project: string; refusals: Refusal[]; keeps: string[]; exempt: string[] }
33
34function parseKernel(text: string): Parsed {
35  let project = 'project'
36  let section = ''
37  const refusals: Refusal[] = []
38  const keeps: string[] = []
39  const exempt: string[] = []
40
41  for (const raw of text.split('\n')) {
42    const line = raw.trim()
43    const title = line.match(/^#\s*kernel\s*[:\-–—]\s*(.+)$/i)
44    if (title) {
45      project = (title[1] ?? '').trim()
46      continue
47    }
48    if (/^#+\s*refuse/i.test(line)) {
49      section = 'refuses'
50      continue
51    }
52    if (/^#+\s*keep/i.test(line)) {
53      section = 'keeps'
54      continue
55    }
56    if (/^#+\s*exempt/i.test(line)) {
57      section = 'exempt'
58      continue
59    }
60    if (/^#+\s/.test(line)) {
61      section = ''
62      continue
63    }
64
65    const r = line.match(/^[-*]\s*(R\d+)\s*[:.)\-–]\s*(.+)$/i)
66    if (r) {
67      const parts = (r[2] ?? '').split('|').map(p => p.trim())
68      const refusal: Refusal = { id: (r[1] ?? '').toUpperCase(), assumption: parts[0] ?? '', tells: [], instead: '' }
69      for (const part of parts.slice(1)) {
70        const tells = part.match(/^tells?\s*:\s*(.*)$/i)
71        const instead = part.match(/^instead\s*:\s*(.*)$/i)
72        if (tells) refusal.tells = (tells[1] ?? '').split(',').map(t => t.trim()).filter(Boolean)
73        if (instead) refusal.instead = instead[1] ?? ''
74      }
75      refusals.push(refusal)
76      continue
77    }
78    if (section === 'keeps' && /^[-*]\s+/.test(line)) keeps.push(line.replace(/^[-*]\s+/, ''))
79    if (section === 'exempt' && /^[-*]\s+/.test(line)) {
80      // the path pattern is the line's first token, `backticks` optional; the rest is a reason
81      const glob = line.replace(/^[-*]\s+/, '').trim().split(/\s+/)[0]!.replace(/^`|`$/g, '')
82      if (glob && !glob.startsWith('<')) exempt.push(glob)
83    }
84  }
85
86  return { project, refusals, keeps, exempt }
87}
88
89// A path pattern, relative to the project: ** any depth, * within one folder, ? one character.
90// A pattern ending in / covers everything under it.
91function globPattern(glob: string): RegExp {
92  let g = glob.replaceAll('\\', '/').replace(/^\.\//, '')
93  if (g.endsWith('/')) g += '**'
94  let re = ''
95  for (let i = 0; i < g.length; i++) {
96    const c = g[i]!
97    if (c === '*' && g[i + 1] === '*') {
98      if (g[i + 2] === '/') {
99        re += '(?:.*/)?' // "**/" : zero or more folders
100        i += 2
101      } else {
102        re += '.*'
103        i += 1
104      }
105    } else if (c === '*') re += '[^/]*'
106    else if (c === '?') re += '[^/]'
107    else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
108  }
109  return new RegExp(`^${re}$`, 'i')
110}
111
112function isExempt(path: string, cwd: string): boolean {
113  if (!parsed.exempt.length) return false
114  const p = path.replaceAll('\\', '/')
115  const root = cwd.replaceAll('\\', '/').replace(/\/+$/, '') + '/'
116  const rel = p.toLowerCase().startsWith(root.toLowerCase()) ? p.slice(root.length) : p.replace(/^\.\//, '')
117  return parsed.exempt.some(g => globPattern(g).test(rel))
118}
119
120function tellPattern(tell: string): RegExp | null {
121  try {
122    const slash = tell.lastIndexOf('/')
123    if (tell.startsWith('/') && slash > 0) return new RegExp(tell.slice(1, slash), tell.slice(slash + 1) || 'i')
124    const escaped = tell.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
125    return new RegExp(`\\b${escaped}\\b`, 'i')
126  } catch {
127    return null
128  }
129}
130
131// ---------- explaining a trip: how the matched text goes against the kernel ----------
132
133const bare = (s: string) => s.trim().replace(/[.;:,\s]+$/, '')
134const firstSentence = (s: string) => bare(s.split(/(?<=[.!?])\s+/)[0] ?? s)
135
136// The words around the match, on its own line, so the reader sees what tripped and where.
137function snippet(text: string, re: RegExp): string {
138  const m = new RegExp(re.source, re.flags.replace('g', '')).exec(text)
139  if (!m) return ''
140  const start = text.lastIndexOf('\n', m.index) + 1
141  const end = text.indexOf('\n', m.index + m[0].length)
142  let line = text.slice(start, end < 0 ? undefined : end).trim().replace(/\s+/g, ' ')
143  if (line.length > 90) {
144    const at = Math.max(0, line.toLowerCase().indexOf(m[0].toLowerCase()) - 30)
145    line = (at > 0 ? '…' : '') + line.slice(at, at + 90) + (at + 90 < line.length ? '…' : '')
146  }
147  return line
148}
149
150/** Two sentences at most: what the edit adds and which refused assumption it marks, then what the design does instead. */
151function explain(refusal: Refusal, tell: string, text: string, re: RegExp): string {
152  const s = snippet(text, re)
153  const one = `The edit adds ${s ? `"${s}", where ` : ''}"${tell}" marks ${refusal.id}'s refused assumption: ${bare(refusal.assumption)}.`
154  return refusal.instead ? `${one} This design does otherwise: ${firstSentence(refusal.instead)}.` : one
155}
156
157// ---------- module state (rebuilt from disk on every reload) ----------
158
159let kernelPath = ''
160let mtime = -1
161let parsed: Parsed = { project: 'project', refusals: [], keeps: [], exempt: [] }
162let patterns: { refusal: Refusal; tell: string; re: RegExp }[] = []
163let section = ''
164let modeOverride: Mode | undefined
165let loaded = false
166// Trips are logged per project folder, so two kernels with the same title keep separate logs.
167let logKey = 'log:'
168
169function renderSection(p: Parsed): string {
170  const refusals = p.refusals
171    .map(r => {
172      const tells = r.tells.length ? ` (tells: ${r.tells.join(', ')})` : ''
173      const instead = r.instead ? ` Instead: ${r.instead}` : ''
174      return `- ${r.id}: ${r.assumption}${tells}.${instead}`
175    })
176    .join('\n')
177  const keeps = p.keeps.length ? `\nKept conventional on purpose: ${p.keeps.join('; ')}.` : ''
178  const exempt = p.exempt.length ? `\nExempt from the edit check (notes there may name refused assumptions to weigh them): ${p.exempt.join(', ')}. Code there is still held to the kernel.` : ''
179
180  return `<deviation_kernel project="${p.project}">
181This project carries a deviation kernel: assumptions it refuses on purpose. Its value depends on these refusals holding while the build fills in ordinary parts, and the usual failure is quiet: a standard component slips in and brings a refused assumption with it.
182
183Refuses:
184${refusals}${keeps}${exempt}
185
186How to work under it:
187- Name each slot by what it must guarantee and what crosses its boundary before naming its category. A category name pulls in its standard filler before any search runs.
188- Take the conventional answer wherever the kernel is not touched, and say in one line when you take it on purpose.
189- Before filling a slot, check the filler against each refusal. When a standard component would re-impose a refused assumption, stop at that step, name the refusal it touches, and hold two or more alternatives open instead of defaulting to the standard one.
190- When an existing library or pattern touches a refusal, go one level into its assumptions before accepting it. It is a candidate, not a stopping point.
191- The result still has to run and hold up in reality. An unconventional part needs a stated mechanism and a way it could fail, not only novelty.
192- The kernel file is the user's. Do not edit it unless asked.
193</deviation_kernel>`
194}
195
196let configuredMode: Mode = 'ask'
197let semantic = true
198
199function mode(): Mode {
200  return modeOverride ?? configuredMode
201}
202
203async function loadKernel($: any, force = false): Promise<boolean> {
204  loaded = true
205  let found = ''
206  for (const path of KERNEL_PATHS) {
207    if (await $.fs.exists(path)) {
208      found = path
209      break
210    }
211  }
212  if (!found) {
213    kernelPath = ''
214    parsed = { project: 'project', refusals: [], keeps: [], exempt: [] }
215    patterns = []
216    section = ''
217    await update($, summary, () => null)
218    return false
219  }
220
221  const stat = await $.fs.stat(found)
222  if (!force && found === kernelPath && stat.mtimeMs === mtime) return true
223
224  kernelPath = found
225  mtime = stat.mtimeMs
226  logKey = `log:${await $.session.cwd()}`
227  parsed = parseKernel(await $.fs.read(found))
228  patterns = []
229  for (const refusal of parsed.refusals) {
230    for (const tell of refusal.tells) {
231      const re = tellPattern(tell)
232      if (re) patterns.push({ refusal, tell, re })
233    }
234  }
235  section = parsed.refusals.length ? renderSection(parsed) : ''
236  await update($, summary, () => ({ project: parsed.project, refusals: parsed.refusals.length, mode: mode() }))
237  return true
238}
239
240async function logFlag($: any, flag: Flag, decision: string) {
241  const log = ((await $.store.get(logKey)) as unknown[] | undefined) ?? []
242  await $.store.set(logKey, [...log, { ...flag, decision }].slice(-50))
243}
244
245async function addPending($: any, flags: Flag[]) {
246  if (!flags.length) return
247  await update($, pending, list => [...list, ...flags].slice(-8))
248}
249
250// ---------- no kernel here: offer, once per folder, to help write one ----------
251
252const OFFER = { now: 'Yes, now', after: 'After this message', later: 'Not this session', never: 'Never in this folder' }
253
254let offer = true
255// Folders already offered this session. A reload asks again; /clear does not.
256const offered = new Set<string>()
257
258function folderKey(path: string): string {
259  return path.replaceAll('\\', '/').replace(/\/+$/, '').toLowerCase()
260}
261
262function interview(lead: string): string {
263  return `[deviation-kernel] ${lead}
264
265How to run it:
266- The folder may already hold work. If it does, read its own record first (README, design notes, the earliest docs or commits) so your questions can point at what the project set out to do. Use what you find to ask, not to answer.
267- Ask the user, one question at a time, which assumptions this project refuses on purpose: ones the standard approach makes and this design rejects. The refusals come from them. Do not propose any of your own.
268- For each refusal they give, help word it, propose tells (words, phrases or /regex/ that would show up in an edit that brings the assumption back) and an "instead:" (what the design does in its place). Two or three refusals are enough to start; /kernel add takes more later.
269- Ask what the project keeps conventional on purpose.
270- Show the whole file in this form, and write it as KERNEL.md at the folder root only when the user says so:
271
272${TEMPLATE}
273- Once it is written, say in one line each what the modes do (ask holds a tripped edit for their decision, warn lets it through and flags it, block refuses it), that the mode is ${mode()}, and that /kernel mode changes it. Say too that tells only check what an edit brings in, not what the files already hold, and offer to read the existing work against the new refusals.`
274}
275
276// Resolves to a note for Claude when the user takes the offer, or undefined.
277async function kernelOffer($: any, e: { command?: unknown; isMeta?: true; origin: { kind: string } }): Promise<string | undefined> {
278  // Only a prompt the user typed; a slash command (/kernel init among them) runs as asked.
279  if (!offer || e.command || e.isMeta || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge')) return undefined
280  const cwd = await $.session.cwd()
281  const key = folderKey(cwd)
282  if (offered.has(key)) return undefined
283  offered.add(key)
284  // A home folder is where sessions start, not a project.
285  const homes = [await $.env.get('HOME'), await $.env.get('USERPROFILE')].filter(Boolean).map(folderKey)
286  if (homes.includes(key) || (await $.store.get(`never:${key}`))) return undefined
287
288  const folder = cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd
289  let answer = OFFER.later
290  try {
291    answer = await $.ui.ask(`There's no KERNEL.md in ${folder}. Want Claude to help you write one?`, {
292      header: 'Kernel',
293      options: [OFFER.now, OFFER.after, OFFER.later, OFFER.never],
294    })
295  } catch {
296    // dismissed, or nobody to ask (claude -p): leave it for this session
297  }
298
299  if (answer === OFFER.now)
300    return interview(`This folder has no KERNEL.md, and the user just chose to have you help write one. Do that before working on their message, and come back to the message once the file is written or they stop.`)
301  if (answer === OFFER.after)
302    return interview(`This folder has no KERNEL.md, and the user chose to have you help write one after this message. Answer their message first, then start.`)
303  if (answer === OFFER.never) {
304    await $.store.set(`never:${key}`, true)
305    $.ui.toast(`kernel: no more offers in ${folder}. /kernel init still works.`)
306    return undefined
307  }
308  if (answer !== OFFER.later && answer.trim())
309    return interview(`This folder has no KERNEL.md. Offered help writing one, the user answered: "${answer.trim()}". Go by that answer; if it asks for the help, run it as below.`)
310  return undefined
311}
312
313
314export const register: Register = (on, options) => {
315  const configured = (options as { mode?: Mode; semanticCheck?: boolean; offerKernel?: boolean }) ?? {}
316  configuredMode = configured.mode ?? 'ask'
317  semantic = configured.semanticCheck !== false
318  offer = configured.offerKernel !== false
319
320  // ---------- session ----------
321
322  on('session.start', async ($, e, next) => {
323    const stored = (await $.store.get('mode')) as Mode | undefined
324    if (stored === 'ask' || stored === 'warn' || stored === 'block') modeOverride = stored
325    await loadKernel($, true)
326    try {
327      await $.command.register({
328        name: 'kernel',
329        description: 'Deviation kernel: show, init, add, mode, check, log, reload',
330        argumentHint: '[init | add <refusal> | mode ask|warn|block | check | log | reload]',
331        immediate: true,
332      })
333    } catch {
334      // name taken: the rest of the mod still works
335    }
336    return next(e)
337  })
338
339  // /clear, /resume and /branch reset $.state, so rebuild the summary
340  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
341    await loadKernel($, true)
342    return next(e)
343  })
344
345  // ---------- the standing seed: the kernel rides in the system prompt ----------
346
347  on('prompt.compose', async ($, e, next) => {
348    if (!loaded) await loadKernel($, true)
349    const composed = await next(e)
350    if (!section) return composed
351    return { sections: [...composed.sections, { id: 'deviation-kernel:kernel', text: section, scope: 'session' as const }] }
352  })
353
354  // ---------- your prompt: refresh the kernel, deliver flagged drift, or offer one ----------
355
356  on('prompt.submit', async ($, e, next) => {
357    if (!(await loadKernel($))) {
358      const note = await kernelOffer($, e)
359      return next(note ? { ...e, context: [...(e.context ?? []), note] } : e)
360    }
361    const flags = await read($, pending)
362    if (!flags.length) return next(e)
363
364    const lines = flags.map(f => {
365      const r = parsed.refusals.find(x => x.id === f.id)
366      return `- ${f.id}${r ? ` (refuses: ${r.assumption})` : ''}: "${f.term}" in ${f.where}${f.why ? ` — ${f.why}` : ''}`
367    })
368    await update($, pending, () => [])
369
370    const note = `[deviation-kernel] A check of the work since your last answer found places that may re-impose refused assumptions:\n${lines.join('\n')}\nDeal with these before building further on that work. If a flag is wrong, say so in one line and continue.`
371    return next({ ...e, context: [...(e.context ?? []), note] })
372  }).catch(($, e, next) => next(e)) // a failed check or offer never holds up the prompt
373
374  // ---------- edits: the step where the conventional filler actually lands ----------
375
376  on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
377    if (!loaded) await loadKernel($, true)
378
379    // Checked before the tells, so a kernel written where there was none takes hold from the next request.
380    const path = e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path
381    if (/(^|[\\/])KERNEL\.md$/i.test(path)) {
382      const ran = await next(e)
383      await loadKernel($, true)
384      return ran
385    }
386    if (!patterns.length) return next(e)
387    if (isExempt(path, await $.session.cwd())) return next(e)
388
389    let added = ''
390    let before = ''
391    if (e.tool === 'Edit') {
392      added = e.new_string
393      before = e.old_string
394    } else if (e.tool === 'Write') {
395      added = e.content
396      // A Write replaces the whole file, so what the file held is the replaced text.
397      try {
398        if (await $.fs.exists(path)) before = await $.fs.read(path)
399      } catch {
400        // unreadable: judge the new content on its own
401      }
402    } else if (e.tool === 'NotebookEdit') {
403      added = e.new_source
404    }
405
406    // Only tells this edit introduces; one already in the replaced text is not new drift.
407    const hit = patterns.find(p => p.re.test(added) && !(before && p.re.test(before)))
408    if (!hit) return next(e)
409
410    const file = path.split(/[\\/]/).pop() ?? path
411    const why = explain(hit.refusal, hit.tell, added, hit.re)
412    const flag: Flag = { id: hit.refusal.id, term: hit.tell, where: file, why, at: await $.clock.now() }
413    const deny = {
414      deny: `Deviation kernel ${hit.refusal.id}, in ${file}. ${why} Rework this part against the refusal. If this use genuinely does not touch the refusal, say why in one line and ask the user before trying again.`,
415    }
416
417    const current = mode()
418    if (current === 'block') {
419      await logFlag($, flag, 'blocked')
420      $.ui.toast(`kernel ${flag.id}: blocked "${flag.term}" in ${file}`)
421      return deny
422    }
423
424    if (current === 'warn') {
425      await logFlag($, flag, 'warned')
426      await addPending($, [flag])
427      $.ui.toast(`kernel ${flag.id}: "${flag.term}" in ${file}`)
428      return next(e)
429    }
430
431    // ask: hold the edit until you decide
432    let answer = 'Refuse it'
433    try {
434      answer = await $.ui.ask(
435        `Kernel ${flag.id}, in ${file}. ${why} Let it through?`,
436        { header: 'Kernel', options: ['Refuse it', 'Allow, on purpose'] },
437      )
438    } catch {
439      // dismissed, or nobody to ask (claude -p): fall through to refuse
440    }
441    if (answer === 'Allow, on purpose') {
442      await logFlag($, flag, 'allowed')
443      return next(e)
444    }
445    await logFlag($, flag, 'refused')
446    return deny
447  }).catch(($, e, next) => (next.called ? next(e) : { deny: 'The deviation-kernel guard failed on this edit, so it was not applied. Ask the user before retrying.' }))
448
449  // ---------- answers: semantic drift the keyword tells miss ----------
450
451  on('turn.complete', async ($, e, next) => {
452    const done = await next(e)
453    if (!semantic || !section || e.agentId || e.isAborted || (e.answer ?? '').length < 300) return done
454
455    const answer = e.answer.slice(-12000)
456    $.clock.after(0, async () => {
457      const r = await $.model.complete({
458        model: 'haiku',
459        effort: 'low',
460        maxTokens: 600,
461        system:
462          'You audit an answer against a project\'s deviation kernel: assumptions the project refuses on purpose. Report only places where the answer adopts, assumes or builds on a refused assumption, including through a standard component that carries it implicitly. Do not report places that name a refused assumption in order to reject or contrast with it. Reply with JSON only: an array of {"id":"R#","quote":"<at most 12 words copied from the answer>","why":"<at most 20 words>"}. Reply [] when nothing trips.',
463        prompt: `${section}\n\nANSWER:\n${answer}`,
464      })
465      if (!r.isAnswered) return
466      const start = r.text.indexOf('[')
467      const end = r.text.lastIndexOf(']')
468      if (start < 0 || end <= start) return
469      let found: { id?: string; quote?: string; why?: string }[] = []
470      try {
471        found = JSON.parse(r.text.slice(start, end + 1))
472      } catch {
473        return
474      }
475      const ids = new Set(parsed.refusals.map(x => x.id))
476      const at = await $.clock.now()
477      const flags: Flag[] = found
478        .filter(f => f && typeof f.id === 'string' && ids.has(f.id.toUpperCase()))
479        .slice(0, 4)
480        .map(f => ({ id: f.id!.toUpperCase(), term: String(f.quote ?? '').slice(0, 120), where: 'last answer', why: String(f.why ?? '').slice(0, 160), at }))
481      if (!flags.length) return
482      await addPending($, flags)
483      for (const f of flags) await logFlag($, f, 'flagged')
484      $.ui.toast(`kernel: ${flags.map(f => f.id).join(', ')} drift in last answer`)
485    })
486    return done
487  })
488
489  // ---------- /kernel ----------
490
491  on('command.run', { command: 'kernel' }, async ($, e) => {
492    const [sub, ...rest] = (e.args ?? '').trim().split(/\s+/)
493    const arg = rest.join(' ').trim()
494    const asMode = (m: string): Mode | undefined => (m === 'ask' || m === 'warn' || m === 'block' ? m : undefined)
495
496    if (sub === 'init') {
497      if (await loadKernel($)) return { text: `A kernel already exists at ${kernelPath}.` }
498      await $.fs.write('KERNEL.md', TEMPLATE)
499      await loadKernel($, true)
500      return { text: 'Wrote KERNEL.md from the template. Fill in the refusals; the mod picks it up on your next prompt.' }
501    }
502
503    if (sub === 'add') {
504      if (!arg) return { text: 'Usage: /kernel add <refused assumption> | tells: a, b | instead: ...' }
505      if (!(await loadKernel($))) await $.fs.write('KERNEL.md', TEMPLATE.replace(/## Refuses[\s\S]*?\n\n/, '## Refuses\n\n'))
506      await loadKernel($, true)
507      const n = parsed.refusals.reduce((max, r) => Math.max(max, Number(r.id.slice(1)) || 0), 0) + 1
508      const text = await $.fs.read(kernelPath)
509      const line = `- R${n}: ${arg}`
510      const updated = /## Refuses\s*\n/i.test(text)
511        ? text.replace(/(## Refuses[^\n]*\n(?:[ \t]*[-*][^\n]*\n|[ \t]*\n)*?)(\n?## |\s*$)/i, (_m, body, tail) => `${body.replace(/\n*$/, '\n')}${line}\n${tail.startsWith('\n') || tail.trim() === '' ? tail : '\n' + tail}`)
512        : `${text.replace(/\n*$/, '\n')}\n## Refuses\n${line}\n`
513      await $.fs.write(kernelPath, updated)
514      await loadKernel($, true)
515      return { text: `Added R${n} to ${kernelPath}. Claude reads it from the next request.` }
516    }
517
518    if (sub === 'mode') {
519      const next = asMode(arg)
520      if (!next) return { text: `Mode is ${mode()}. Use /kernel mode ask|warn|block.` }
521      modeOverride = next
522      await $.store.set('mode', next)
523      await update($, summary, s => (s ? { ...s, mode: next } : s))
524      return { text: `Kernel mode: ${next}.` }
525    }
526
527    if (sub === 'reload') {
528      const ok = await loadKernel($, true)
529      return { text: ok ? `Reloaded ${kernelPath}: ${parsed.refusals.length} refusals.` : 'No KERNEL.md here. Run /kernel init.' }
530    }
531
532    if (sub === 'log') {
533      const log = ((await $.store.get(logKey)) as (Flag & { decision: string })[] | undefined) ?? []
534      if (!log.length) return { text: 'No kernel trips logged for this project yet.' }
535      return { text: log.slice(-15).map(f => `${f.id} ${f.decision}: "${f.term}" in ${f.where}${f.why ? ` (${f.why})` : ''}`).join('\n') }
536    }
537
538    if (sub === 'check') {
539      if (!section) return { text: 'No kernel loaded. Run /kernel init.' }
540      const r = await $.model.fork({
541        prompt: `${section}\n\nAudit the work in this conversation so far against the kernel above. For each refusal, say whether the design or code has quietly re-imposed it, quoting the place. Then list conventional choices made on purpose that do not touch the kernel. Be specific and short. If nothing trips, say so.`,
542      })
543      return { text: r.isAnswered ? `Kernel audit:\n${r.text}` : 'The kernel audit could not run just now.' }
544    }
545
546    // bare /kernel: show it
547    if (!(await loadKernel($))) return { text: 'No KERNEL.md in this project. Run /kernel init to write a template, or /kernel add <refusal>.' }
548    await $.ui.open({ id: PANE, title: `Kernel: ${parsed.project}`, focus: true, closeOnEscape: true })
549    return {}
550  })
551
552  // ---------- drawing ----------
553
554  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
555    const s = await read($, summary)
556    if (!s || e.props.hasSurvey) return next(e)
557    const flags = await read($, pending)
558    const { Box, Text, Button } = $.ui.resolve(e)
559
560    const children: any[] = [Text({ dimColor: true, children: [`◆ kernel · ${s.project} · ${s.refusals} refusals · ${s.mode}`] })]
561    if (flags.length) {
562      children.push(Text({ color: 'yellow', children: [` · ⚠ ${flags.map(f => f.id).join(', ')} drift → goes to Claude with your next prompt `] }))
563      children.push(Button({ key: 'drop', label: 'drop', plain: true, dimColor: true, onPress: () => update($, pending, () => []) }))
564    }
565    const theirs = await next(e)
566    return Box({ flexDirection: 'column', children: [Box({ flexDirection: 'row', children }), ...(theirs ? [theirs] : [])] })
567  })
568
569  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
570    const { Markdown } = $.ui.resolve(e)
571    const log = ((await $.store.get(logKey)) as (Flag & { decision: string })[] | undefined) ?? []
572    const refusals = parsed.refusals
573      .map(r => `**${r.id}** ${r.assumption}${r.tells.length ? `  \n  tells: ${r.tells.join(', ')}` : ''}${r.instead ? `  \n  instead: ${r.instead}` : ''}`)
574      .join('\n\n')
575    const keeps = parsed.keeps.length ? `\n\n**Kept conventional:** ${parsed.keeps.join('; ')}` : ''
576    const exempt = parsed.exempt.length ? `\n\n**Exempt from the edit check:** ${parsed.exempt.join(', ')}` : ''
577    const trips = log.length
578      ? `\n\n**Recent trips**\n\n${log.slice(-8).reverse().map(f => `- ${f.id} ${f.decision}: "${f.term}" in ${f.where}${f.why ? `  \n  ${f.why}` : ''}`).join('\n')}`
579      : ''
580    return Markdown({ key: 'kernel', text: `Mode: **${mode()}** · file: \`${kernelPath}\`\n\n${refusals}${keeps}${exempt}${trips}` })
581  })
582}
583
types/index.d.ts 31 lines
1export type Mode = 'ask' | 'warn' | 'block'
2
3/** One refused assumption, parsed from KERNEL.md. */
4export type Refusal = {
5  id: string
6  assumption: string
7  tells: string[]
8  instead: string
9}
10
11/** A place where work re-imposed a refusal. */
12export type Flag = {
13  id: string
14  term: string
15  where: string
16  why: string
17  at: number
18}
19
20export type KernelSummary = {
21  project: string
22  refusals: number
23  mode: Mode
24} | null
25
26declare module 'claude-code' {
27  interface PluginState {
28    'deviation-kernel': { pending: Flag[]; summary: KernelSummary }
29  }
30}
31