SLOPSHOPPER

learning-gate

Enforces the opt-in learning-guardrails skill: keeps Claude from loading it unasked, blocks code edits while a checkpoint is open (one left from an earlier…

newbandguardcommandtoaststatus
★ 15v0.3.1no licenseupdated 2026-10-092Steaks/skills/plugins/learning-gate
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · learning-gate
› 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 › /learned ⎿ learning-gate: Nothing recorded yet. The record fills in as concepts come up. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

learning-gate

Enforcement for the opt-in learning-guardrails skill.

  • Claude can't load the skill unless you run /learning-guardrails or name it.
  • While a checkpoint is open, code edits are refused and the question is pinned above the prompt.
  • A checkpoint left open when you close Claude Code is parked: nothing is blocked, the band says ⏸ Paused checkpoint · /pickup to carry on.
  • /pickup loads the skill, recaps where you were and re-asks the question. The gate holds again from there.
  • /learned shows what your record says you've demonstrated. /audit runs the skill's self-evaluation.

The record lives in .learning/ and is kept out of git via .git/info/exclude.

Source 2 files
hooks/register.tsx 512 lines
1// learning-gate: the enforcement half of the learning-guardrails skill.
2// The skill is opt-in: Claude may load it only when the user's latest message runs
3// /learning-guardrails or names it (/pickup and /audit do); any other attempt is refused.
4// The skill writes .learning/gate.lock (one line: `TIER · category · question`)
5// right before it asks a checkpoint question, and deletes it when the user
6// passes, pauses, drops the task, or gives a valid override. While it exists:
7// - code edits are refused, with the open question as the reason;
8// - the question is pinned above the prompt;
9// - every prompt carries it to the model, so it survives a compacted context.
10// Edits inside any .learning/ folder always pass, so the record can update.
11// A project's .learning/ is kept out of git via the repo's local exclude list.
12// A reply that ends on a `👉 Your move:` question with no lock open opens one,
13// so a checkpoint Claude forgot to lock still holds. It never overwrites a lock.
14// It logs the mechanical events (triage, gate open/close, refusals) to
15// .learning/events.jsonl as "by":"mod"; the skill logs the judgement calls.
16// A checkpoint left open from an earlier session is parked, not enforced: the band
17// says so and /pickup resumes it (loading the skill). Nothing blocks until then.
18// A hook that fails is skipped: the gate fails open, never locking you out.
19import { atom, read, update } from 'claude-code'
20import type { EngineInterface, Register } from 'claude-code'
21
22import type { GateLock } from '../types'
23
24type Engine = EngineInterface
25
26const lockState = atom({ plugin: 'learning-gate', key: 'lock' } as const, null)
27const triageState = atom({ plugin: 'learning-gate', key: 'triage' } as const, null)
28const activeState = atom({ plugin: 'learning-gate', key: 'active' } as const, false)
29const namedState = atom({ plugin: 'learning-gate', key: 'named' } as const, false)
30
31const LOCK = '.learning/gate.lock'
32const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
33const TRIAGE = /Guardrail · (GO|CHECK|GATE) · ([^\n`]+)/g
34const SKILL = 'learning-guardrails'
35const NAMES_SKILL = /learning[\s_-]*guardrails/i
36const isThisSkill = (name: unknown) => typeof name === 'string' && (name === SKILL || name.endsWith(`:${SKILL}`))
37const OPT_IN = `${SKILL} is opt-in: it loads only when the user runs /${SKILL} or names it. Carry on without it.`
38
39const RULES = {
40  id: 'learning-gate:rules',
41  scope: 'session',
42  text: [
43    'The learning-gate plugin is installed. It enforces the checkpoints the learning-guardrails skill opens, and nothing else.',
44    'The learning-guardrails skill is opt-in: load it only when the user runs /learning-guardrails or names it in their latest message. Never load it on your own, even for coding tasks.',
45    `While ${LOCK} exists in the project, the plugin refuses code edits and pins the open question above the prompt. Close a checkpoint (delete the lock) only as the skill allows, and never offer the user a way around one.`,
46    "The plugin replaces the skill's hook script: do not install learning-gate.mjs.",
47  ].join('\n'),
48} as const
49
50const PICKUP =
51  'Pick up where we left off, per the learning-guardrails skill: a three-line recap from .learning/session.md, then any open question in full.'
52const AUDIT = 'Audit the learning guardrails, per section 10 of the learning-guardrails skill.'
53
54export const register: Register = on => {
55  on('session.start', async ($, e, next) => {
56    await Promise.all([
57      $.command.register({ name: 'learned', description: 'Show what your learning record says you have demonstrated' }),
58      $.command.register({ name: 'pickup', description: 'Pick up the open learning checkpoint where you left off' }),
59      $.command.register({ name: 'audit', description: 'Audit the learning guardrails against their own logs' }),
60    ])
61    await refresh($)
62
63    return next(e)
64  })
65
66  // Opt-in: the skill is in play once its prompt is expanded (/name, or an allowed Skill call).
67  on('skill.prompt', async ($, e, next) => {
68    if (isThisSkill(e.skill)) await update($, activeState, () => true)
69
70    return next(e)
71  })
72
73  // The gate: refuse Claude loading the skill unasked, and code edits while a checkpoint is open.
74  on('tool.call', async ($, e, next) => {
75    const tool = String(e.tool)
76    let touchesRecord = false
77
78    if (tool === 'Skill' && isThisSkill((e as { skill?: unknown }).skill) && !(await read($, namedState))) {
79      return { deny: OPT_IN }
80    }
81
82    if (EDIT_TOOLS.has(tool)) {
83      const cwd = await $.session.cwd()
84      const target = editTarget(e)
85      const file = target === undefined ? undefined : resolvePath(cwd, target)
86      touchesRecord = file !== undefined && isInRecord(file)
87
88      // Opt-in: a lock only blocks once the skill is in play this session.
89      if (!touchesRecord && (await read($, activeState))) {
90        const lock = await readLock($, file === undefined ? [cwd] : [parentOf(file), cwd])
91        if (lock !== null) {
92          await setLock($, lock)
93          await logEvent($, { e: 'edit_refused', tier: lock.tier.toLowerCase(), label: lock.category, tool })
94
95          return { deny: blockMessage(lock) }
96        }
97      }
98    }
99
100    const ran = await next(e)
101    if (touchesRecord) {
102      const target = editTarget(e)
103      if (target !== undefined) await keepPrivate($, resolvePath(await $.session.cwd(), target))
104    }
105    // The lock is written with Write and deleted with Bash: look again after either.
106    if (touchesRecord || tool === 'Bash') await refresh($)
107
108    return ran
109  }).catch(($, e, next) => next(e))
110
111  // Keep the open question in front of the model on every prompt.
112  on('prompt.submit', async ($, e, next) => {
113    const named = NAMES_SKILL.test(e.text)
114    await update($, namedState, () => named)
115    const lock = await refresh($)
116    // Parked checkpoints stay quiet until the user opts back in.
117    if (lock === null || !(named || (await read($, activeState)))) return next(e)
118    const note =
119      `Learning checkpoint open (${label(lock)}): "${lock.question}" ` +
120      'Read this message as the answer unless it plainly is not one (a pickup request is not: re-ask the question in full), and judge it by the learning-guardrails evidence bar. ' +
121      `Code edits stay refused until the checkpoint closes: delete ${lock.path} only as the learning-guardrails skill allows.`
122
123    return next({ ...e, context: [...(e.context ?? []), note] })
124  }).catch(($, e, next) => next(e))
125
126  // Always on: the rule rides in the system prompt, not on a skill match.
127  on('prompt.compose', async ($, e, next) => {
128    const composed = await next(e)
129
130    return { sections: [...composed.sections, RULES] }
131  })
132
133  // The status line: the open checkpoint, else the last triage line.
134  on('session.append', { door: 'response' }, async ($, e, next) => {
135    if (e.agentId === undefined) {
136      const text = e.message.content.flatMap(block => (block.type === 'text' ? [block.text] : [])).join('\n')
137      const found = [...text.matchAll(TRIAGE)]
138      const last = found.at(-1)?.[0].trim()
139      if (last !== undefined) {
140        await update($, triageState, () => last)
141        showStatus($, await read($, lockState), last, await read($, activeState))
142      }
143      for (const [, tier, what] of found) {
144        await logEvent($, { e: 'triage', tier: (tier ?? '').toLowerCase(), what: (what ?? '').trim().slice(0, 80) })
145      }
146      const asked = (await read($, activeState)) ? askedCheckpoint(text, await read($, triageState)) : null
147      if (asked !== null) await lockIfOpen($, asked)
148    }
149
150    return next(e)
151  })
152
153  // The band above the prompt: the open question, pinned until answered.
154  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
155    const lock = await read($, lockState)
156    if (lock === null || e.props.hasSurvey) return next(e)
157    const { Box, Text } = $.ui.resolve(e)
158
159    if (!(await read($, activeState))) {
160      return (
161        <Text dimColor wrap="wrap">
162          ⏸ Paused checkpoint · {label(lock)} · /pickup to carry on
163        </Text>
164      )
165    }
166
167    return (
168      <Box flexDirection="column">
169        <Text color="warning" bold>
170          🛑 Checkpoint open · {label(lock)}
171        </Text>
172        <Text wrap="wrap">{lock.question}</Text>
173        <Text dimColor>Answer below. You can stop; you can't skip.</Text>
174      </Box>
175    )
176  })
177
178  on('command.run', { command: 'learned' }, async $ => ({ text: await learnedSummary($) }))
179
180  on('command.run', { command: 'pickup' }, async $ => {
181    await optIn($)
182    void runSkill($, PICKUP)
183
184    return {}
185  })
186
187  on('command.run', { command: 'audit' }, async $ => {
188    await optIn($)
189    void runSkill($, AUDIT)
190
191    return {}
192  })
193}
194
195// ---- opting in ----
196
197// /pickup and /audit are the user asking for the skill: let it load and enforce.
198async function optIn($: Engine): Promise<void> {
199  await update($, namedState, () => true)
200  await update($, activeState, () => true)
201  await refresh($)
202}
203
204// Load the skill the way the user would (/learning-guardrails <ask>), else ask in words.
205async function runSkill($: Engine, ask: string): Promise<void> {
206  try {
207    const found = (await $.command.list()).find(c => isThisSkill(c.name))
208    if (found !== undefined) {
209      await $.command.run({ command: found.name, args: ask })
210
211      return
212    }
213  } catch {
214    // fall through to a plain prompt
215  }
216  await $.prompt.submit({ text: ask, asUser: true })
217}
218
219// ---- the lock ----
220
221async function refresh($: Engine): Promise<GateLock | null> {
222  const lock = await readLock($, [await $.session.cwd()])
223  await setLock($, lock)
224  if (lock !== null) await keepPrivate($, lock.path)
225
226  return lock
227}
228
229async function readLock($: Engine, starts: readonly string[]): Promise<GateLock | null> {
230  const path = await findUp($, starts, LOCK)
231  if (path === null) return null
232  try {
233    return parseLock(path, await $.fs.read(path))
234  } catch {
235    return null // deleted between the look and the read
236  }
237}
238
239async function setLock($: Engine, lock: GateLock | null): Promise<void> {
240  const before = await read($, lockState)
241  if (JSON.stringify(before) !== JSON.stringify(lock)) {
242    await update($, lockState, () => lock)
243    if (before !== null && lock === null) $.ui.toast('Gate lifted. Carry on.')
244  }
245  await trackGate($, lock)
246  showStatus($, lock, await read($, triageState), await read($, activeState))
247}
248
249// Which gates are open, by lock path, kept across sessions: a lock seen again
250// in a new session is not a new gate, and a rewritten question is the same one.
251type OpenGate = { at: number; tier: string; label: string }
252const GATES = 'gates'
253
254async function trackGate($: Engine, lock: GateLock | null): Promise<void> {
255  try {
256    const gates = { ...(((await $.store.get(GATES)) ?? {}) as Record<string, OpenGate>) }
257    // A record deleted outright takes its open gate with it (nowhere left to log).
258    let pruned = false
259    for (const stored of Object.keys(gates)) {
260      if (!(await $.fs.exists(parentOf(stored)))) {
261        delete gates[stored]
262        pruned = true
263      }
264    }
265    if (pruned) await $.store.set(GATES, gates)
266    const root = await projectRecord($)
267    if (root === null) return
268    const path = join(root, 'gate.lock')
269    const open = gates[path]
270    if (lock !== null && lock.path === path && open === undefined) {
271      const at = await $.clock.now()
272      await $.store.set(GATES, { ...gates, [path]: { at, tier: lock.tier, label: lock.category } })
273      await logEvent($, { e: 'gate_open', tier: lock.tier.toLowerCase(), label: lock.category })
274    } else if ((lock === null || lock.path !== path) && open !== undefined) {
275      const rest = { ...gates }
276      delete rest[path]
277      await $.store.set(GATES, rest)
278      const ms = (await $.clock.now()) - open.at
279      await logEvent($, { e: 'gate_close', tier: open.tier.toLowerCase(), label: open.label, ms })
280    }
281  } catch {
282    // tracking must never get in the way of the gate
283  }
284}
285
286function showStatus($: Engine, lock: GateLock | null, triage: string | null, active: boolean): void {
287  if (lock !== null) $.ui.status(active ? `🛑 Checkpoint open · ${label(lock)}` : `⏸ Paused checkpoint · ${label(lock)}`)
288  else $.ui.status(triage ?? undefined)
289}
290
291export function parseLock(path: string, text: string): GateLock {
292  const line = text.trim().replace(/\s*\n\s*/g, ' ')
293  const [tier, category, ...rest] = line.split(' · ')
294  if (tier !== undefined && category !== undefined && rest.length > 0 && /^(GO|CHECK|GATE)$/i.test(tier.trim())) {
295    return { path, tier: tier.trim().toUpperCase(), category: category.trim(), question: rest.join(' · ').trim() }
296  }
297
298  return { path, tier: 'CHECKPOINT', category: '', question: line || 'See .learning/session.md' }
299}
300
301const label = (lock: GateLock) => [lock.tier, lock.category].filter(Boolean).join(' · ')
302
303const blockMessage = (lock: GateLock) =>
304  [
305    'Learning checkpoint open: code edits are refused until the user shows they understand.',
306    `Open question: ${lock.question}`,
307    'Re-ask the question in full and end your turn.',
308    `Close the checkpoint (delete ${lock.path}) only as the learning-guardrails skill allows. Never offer the user a way around it.`,
309  ].join('\n')
310
311function editTarget(e: unknown): string | undefined {
312  const input = e as { file_path?: unknown; notebook_path?: unknown }
313  if (typeof input.file_path === 'string') return input.file_path
314  if (typeof input.notebook_path === 'string') return input.notebook_path
315
316  return undefined
317}
318
319// ---- a question asked without a lock ----
320
321type Asked = { tier: string; label: string; question: string }
322
323const MOVE = /👉\s*(?:\*\*)?Your move(?::\*\*|\*\*:|:)\s*(.+)/g
324
325// The last `👉 Your move:` question in a reply, with its tier and label read
326// from the checkpoint header, an Own-it line, or the last triage line.
327export function askedCheckpoint(text: string, triage: string | null): Asked | null {
328  const question = [...text.matchAll(MOVE)].at(-1)?.[1]?.replace(/\*\*/g, '').trim()
329  if (!question) return null
330  if (/\bOwn it\b/i.test(text)) return { tier: 'GATE', label: 'own it', question }
331  const header = text.split('\n').find(line => /^\s*Checkpoint \d+\/\d+ · /.test(line))
332  const fromHeader = header?.split(' · ').at(-1)?.trim()
333  const triaged = triage === null ? null : /Guardrail · (GO|CHECK|GATE) · ([^:\n]+)/.exec(triage)
334  const tier = triaged?.[1] === 'GATE' ? 'GATE' : 'CHECK'
335  const label = fromHeader || triaged?.[2]?.trim() || 'checkpoint'
336
337  return { tier, label, question }
338}
339
340async function lockIfOpen($: Engine, asked: Asked): Promise<void> {
341  try {
342    const cwd = await $.session.cwd()
343    if ((await readLock($, [cwd])) !== null) return // Claude's own lock stands
344    const root = (await projectRecord($)) ?? join(cwd, '.learning')
345    await $.fs.write(join(root, 'gate.lock'), `${asked.tier} · ${asked.label} · ${asked.question}\n`)
346    await refresh($)
347  } catch {
348    // the skill still holds the line if this fails
349  }
350}
351
352// ---- the log ----
353
354// Best effort: a failure here never touches the gate. Writes go one at a
355// time, so two hooks logging at once can't drop each other's lines.
356let logQueue: Promise<void> = Promise.resolve()
357
358function logEvent($: Engine, event: Record<string, unknown>): Promise<void> {
359  logQueue = logQueue.then(() => writeEvent($, event))
360
361  return logQueue
362}
363
364async function writeEvent($: Engine, event: Record<string, unknown>): Promise<void> {
365  try {
366    const root = await projectRecord($)
367    if (root === null) return
368    const path = join(root, 'events.jsonl')
369    const line = JSON.stringify({ d: new Date(await $.clock.now()).toISOString(), by: 'mod', ...event })
370    const before = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
371    await $.fs.write(path, `${before}${before === '' || before.endsWith('\n') ? '' : '\n'}${line}\n`)
372  } catch {
373    // logging must never get in the way
374  }
375}
376
377// This project's .learning/ folder: never the general one under HOME.
378async function projectRecord($: Engine): Promise<string | null> {
379  const found = await findUp($, [await $.session.cwd()], '.learning')
380  const home = await $.env.get('HOME')
381  if (found === null || (home !== undefined && found === join(clean(home), '.learning'))) return null
382
383  return found
384}
385
386// ---- privacy ----
387
388const privateRoots = new Set<string>()
389
390// Keeps a project's .learning/ out of git with one line in the repo's own
391// .git/info/exclude: local to this machine, never the team's .gitignore.
392async function keepPrivate($: Engine, file: string): Promise<void> {
393  const parts = clean(file).split('/')
394  const at = parts.lastIndexOf('.learning')
395  if (at < 1) return
396  const root = parts.slice(0, at).join('/') || '/'
397  if (privateRoots.has(root)) return
398  const exclude = join(root, '.git/info/exclude')
399  if (!(await $.fs.exists(exclude))) return
400  const text = await $.fs.read(exclude)
401  if (!/^\/?\.learning\/?$/m.test(text)) {
402    await $.fs.write(exclude, `${text}${text === '' || text.endsWith('\n') ? '' : '\n'}.learning/\n`)
403    $.ui.toast('Kept .learning/ out of git (local exclude only).')
404  }
405  privateRoots.add(root)
406}
407
408// ---- /learned ----
409
410type Concept = { name: string; status: string; gaps: number }
411
412const GROUPS: readonly (readonly [string, string])[] = [
413  ['demonstrated', 'Demonstrated'],
414  ['learning', 'Learning'],
415  ['needs-reinforcement', 'Needs reinforcement'],
416  ['unfamiliar', 'Unfamiliar'],
417]
418
419async function learnedSummary($: Engine): Promise<string> {
420  const home = await $.env.get('HOME')
421  const dirs: string[] = []
422  if (home !== undefined) dirs.push(join(clean(home), '.learning/general'))
423  const project = await findUp($, [await $.session.cwd()], '.learning/project')
424  if (project !== null && !dirs.includes(project)) dirs.push(project)
425
426  const concepts: Concept[] = []
427  for (const dir of dirs) {
428    if (!(await $.fs.exists(dir))) continue
429    for (const entry of await $.fs.list(dir)) {
430      if (entry.kind !== 'file' || !entry.name.endsWith('.md')) continue
431      concepts.push(parseConcept(entry.name, await $.fs.read(join(dir, entry.name))))
432    }
433  }
434
435  return summarise(concepts)
436}
437
438export function parseConcept(file: string, text: string): Concept {
439  const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? ''
440  const field = (key: string) => new RegExp(`^${key}:[ \\t]*([^#\\n]*)`, 'm').exec(front)?.[1]?.trim()
441  const gaps = /^## Known gaps[ \t]*\r?\n([\s\S]*?)(?=^## |(?![\s\S]))/m.exec(text)?.[1] ?? ''
442
443  return {
444    name: field('concept') || file.replace(/\.md$/, ''),
445    status: field('status') || 'unknown',
446    gaps: gaps.split('\n').filter(line => /^\s*- /.test(line)).length,
447  }
448}
449
450export function summarise(concepts: readonly Concept[]): string {
451  if (concepts.length === 0) return 'Nothing recorded yet. The record fills in as concepts come up.'
452  const names = (list: readonly Concept[]) => {
453    const shown = list.slice(0, 8).map(c => c.name)
454    return list.length > 8 ? `${shown.join(', ')}, +${list.length - 8} more` : shown.join(', ')
455  }
456  const known = new Set(GROUPS.map(([status]) => status))
457  const lines = [`Learning record: ${concepts.length} concept${concepts.length === 1 ? '' : 's'}`]
458  for (const [status, title] of GROUPS) {
459    const list = concepts.filter(c => c.status === status)
460    if (list.length > 0) lines.push(`${title} (${list.length}): ${names(list)}`)
461  }
462  const other = concepts.filter(c => !known.has(c.status))
463  if (other.length > 0) lines.push(`Other (${other.length}): ${names(other)}`)
464  const gaps = concepts.reduce((sum, c) => sum + c.gaps, 0)
465  if (gaps > 0) lines.push(`Open gaps: ${gaps}`)
466
467  return lines.join('\n')
468}
469
470// ---- paths (POSIX) ----
471
472export function clean(path: string): string {
473  const parts: string[] = []
474  for (const part of path.replace(/\\/g, '/').split('/')) {
475    if (part === '' || part === '.') continue
476    if (part === '..') parts.pop()
477    else parts.push(part)
478  }
479
480  return '/' + parts.join('/')
481}
482
483export const resolvePath = (cwd: string, path: string) => clean(path.startsWith('/') ? path : `${cwd}/${path}`)
484
485export const isInRecord = (file: string) => clean(file).split('/').includes('.learning')
486
487function ancestors(dir: string): string[] {
488  const parts = clean(dir).split('/').filter(Boolean)
489  const out: string[] = []
490  for (let i = parts.length; i >= 0; i--) out.push('/' + parts.slice(0, i).join('/'))
491
492  return out
493}
494
495const parentOf = (file: string) => ancestors(file)[1] ?? '/'
496
497const join = (dir: string, rest: string) => `${dir === '/' ? '' : dir}/${rest}`
498
499async function findUp($: Engine, starts: readonly string[], rest: string): Promise<string | null> {
500  const seen = new Set<string>()
501  for (const start of starts) {
502    for (const dir of ancestors(start)) {
503      if (seen.has(dir)) continue
504      seen.add(dir)
505      const path = join(dir, rest)
506      if (await $.fs.exists(path)) return path
507    }
508  }
509
510  return null
511}
512
types/index.d.ts 27 lines
1/** The open checkpoint, as read from `.learning/gate.lock`. */
2export type GateLock = {
3  /** Absolute path of the lock file. */
4  path: string
5  /** GO, CHECK or GATE as the skill wrote it; `CHECKPOINT` when unparsed. */
6  tier: string
7  /** The risk category (`concurrency`, `data`, ...); may be empty. */
8  category: string
9  /** The open question, on one line. */
10  question: string
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    'learning-gate': {
16      /** The open checkpoint, or null when none is open. */
17      lock: GateLock | null
18      /** The last `Guardrail · TIER · ...` line the model wrote. */
19      triage: string | null
20      /** Whether the learning-guardrails skill was expanded this session. */
21      active: boolean
22      /** Whether the user's latest prompt named the skill (lets Claude load it). */
23      named: boolean
24    }
25  }
26}
27