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…

Enforcement for the opt-in learning-guardrails skill.
/learning-guardrails or name it.⏸ 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.
hooks/register.tsx 512 lines1// 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}
512types/index.d.ts 27 lines1/** 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