Displays lesson hint cards when tool calls match known patterns. Additive surface during pilot; the classic --strict guard keeps its 12 block-severity denies.

Displays lesson hint cards when tool calls match known patterns. Additive surface during pilot; the classic --strict guard keeps its 12 block-severity denies.
Requires Claude Code 2.1.266 or later (first measured classic.* binary).
bash cp -r mods/lesson-cards/ /path/to/your/project/mods/ ``bash export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ``Or in ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}
bash claude --plugin-dir mods/ ``~/.claude/hq/floor-*/lessons.md and indexes by command tokens$.ui.ask) before the call runs. Only an explicit Proceed anyway runs the call. Cancel, Escape, a typed free-text answer, and no dialog at all (headless -p) refuse it with { deny }, so it never runs. The card is the only place the lesson text is drawn. The question is the lesson id and Proceed anyway? only. A user cancel (Cancel or Escape) is Cancelled: by you; not run. [lesson:<id>] with no Fix (a cancel is not a failure). The Cancelled: head is ours so Claude Code 2.1.283 leaves the deny alone instead of gluing Error: ; the rest drops the echo so Cancelled is said once. Other refuses (no dialog, no exact Proceed anyway) still carry a short Fix so the model keeps the alternative. When several patterns match an Edit or Write, a block match always ranks before a warnpretool-lesson-guard --strict keeps its 12 block-severity denies unchanged| Event | Matcher | Notes |
|---|---|---|
session.start | {} | Load corpus (patterns + bullets) |
tool.call | { tool: 'Bash' } | Match command, return context |
tool.call | { tool: 'Edit' } | Match file/content, return context |
tool.call | { tool: 'Write' } | Match file/content, return context |
ui.render | { component: 'ToolUse' } | Wrap the row and the card in a column Box built from $.ui.resolve(e) |
command.register | {} | Handle /lessons command |
$.fs.read - Read lesson-patterns.json and lessons.md files$.fs.list - List hq-ext versions and floor directories$.fs.stat - Get file mtimes for sorting$.ui.notice - Show hint during permission dialog (if open)$.ui.ask - Ask "Proceed anyway?" before a block-severity call runs$.ui.resolve - The element constructors (Box, Text) the card is built from. On CC 2.1.282 a render hook may only return nodes built by these; a plain { type: 'Box' } object draws nothing, and the tree next(e) returns is an opaque engine node that must be wrapped, never mutated$.ui.invalidate - Refresh UI after /lessons reloadprocess.runhttp.fetchtool.call (all reads happen at session.start)bash rm -rf mods/lesson-cards # or claude plugin disable lesson-cards ``bash unset CLAUDE_CODE_ENABLE_FUNCTION_HOOKS ``No state persisted except the in-memory match map, which is safe to drop.
/lessons - Reload corpus from hq-ext cache and lessons.md fileslesson-cards matches before the tool runs, so its card and its question come before the classic guards inside next(e). With hq-ext installed, a block pattern the user answers Proceed anyway still meets pretool-lesson-guard, which keeps its deny. The quoted parts of a command are blanked before matching (shell-faithful), so a pattern inside an echo "..." string matches nothing; it has to be in the command itself. Patterns with a repos list (for example the platform ones) match only in that repo.
Harmless ways to see each surface (measured on CC 2.1.282, 2026-09-25):
echo pgvector/pgvector:pg16 matches pgvector-pg-version-mismatch and draws a yellow card under the row.git ls-files mods | grep register matches git-ls-files-quotepath-blind, draws a red card and asks Proceed anyway?; Cancel denies the call.Other ways that also work:
/lessons command reloads corpusLow. Purely additive during pilot; the only behavioral change is one extra context line per matched call.
hooks/register.ts 360 lines1/**
2 * Hook registration for lesson-cards.
3 *
4 * Events:
5 * - session.start: Load corpus (patterns + bullets)
6 * - tool.call{tool=Bash}: Match command, return context
7 * - tool.call{tool=Edit}: Match file/content, return context
8 * - tool.call{tool=Write}: Match file/content, return context
9 * - ui.render{component=ToolUse}: Append card under tool row
10 * - command.run{command=lessons}: /lessons reload (declared with $.command.register at session start)
11 *
12 * Hooks module format: exports register(on, options); $ is only ever used
13 * as $.noun.event(...), so all $-taking helpers live in this file.
14 */
15
16import {
17 parseLessonsMd,
18 joinPath,
19 type Corpus,
20 type LessonPattern,
21 type LessonBullet,
22} from '../src/corpus.js';
23import { matchAll, matchFileEdit } from '../src/match.js';
24import { askQuestion, buildCard, denyLine, formatContext, type CardElements } from '../src/card.js';
25import type { MatchedLesson } from '../src/types.js';
26
27/** Minimal $ facade for the events this module uses. */
28type Hook$ = {
29 env: {
30 get: (name: string) => Promise<string | undefined>;
31 };
32 fs: {
33 read: (path: string) => Promise<string>;
34 list: (path: string) => Promise<Array<{ name: string; kind: 'file' | 'dir' | 'other' }> | null>;
35 stat: (path: string) => Promise<{ size: number; mtimeMs: number; kind: 'file' | 'dir' | 'other' } | null>;
36 };
37 ui: {
38 notice: (toolUseId: string, message: string) => Promise<void>;
39 invalidate: (component: string) => Promise<void>;
40 /** The AskUserQuestion dialog: resolves to the label the user picked. */
41 ask: (question: string, options: readonly string[]) => Promise<unknown>;
42 /** The element constructors this render may return (Box, Text, ...). */
43 resolve: (e: UiRenderEvent) => Promise<CardElements>;
44 };
45 command: {
46 register: (spec: { name: string; description: string }) => Promise<unknown>;
47 };
48};
49
50type ToolCallEvent = {
51 tool: string;
52 tool_use_id?: string;
53 [argument: string]: unknown;
54};
55
56type UiRenderEvent = {
57 component: string;
58 requestId?: string;
59};
60
61/** The two answers the block-lesson dialog offers. */
62export const PROCEED = 'Proceed anyway';
63export const CANCEL = 'Cancel';
64
65/**
66 * What the $.ui.ask throw carries when the person presses Escape. CC 2.1.283
67 * puts its tool rejection text there; the dismiss text is its fallback when
68 * the dialog returns no text.
69 */
70const DISMISSED = ["The user doesn't want to proceed with this tool use", 'the dialog was dismissed'];
71
72type NextFn<E> = (ev: E) => Promise<unknown>;
73
74type SessionStartEvent = { cwd: string; isInteractive: boolean };
75
76// Module-scope state (per session)
77let corpus: Corpus | null = null;
78/** True only after session.start says isInteractive; headless (-p) or no session.start never asks. */
79let interactive = false;
80const matchMap = new Map<string, MatchedLesson[]>();
81
82/**
83 * Find the newest hq-ext version directory.
84 */
85async function findNewestHqExt($: Hook$, home: string): Promise<string | null> {
86 const cacheBase = joinPath(home, '.claude', 'plugins', 'cache', 'yonatan-hq', 'hq-ext');
87
88 try {
89 const entries = await $.fs.list(cacheBase);
90 if (!entries || entries.length === 0) return null;
91
92 // Sort by version, highest first
93 const versions = entries
94 .filter(e => e.kind === 'dir')
95 .map(e => e.name)
96 .sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
97
98 return versions.length > 0 ? joinPath(cacheBase, versions[0]) : null;
99 } catch {
100 return null;
101 }
102}
103
104/**
105 * Load lesson-patterns.json from hq-ext cache.
106 */
107async function loadPatterns($: Hook$, hqExtPath: string): Promise<LessonPattern[]> {
108 const configPath = joinPath(hqExtPath, 'configs', 'lesson-patterns.json');
109 try {
110 const content = await $.fs.read(configPath);
111 if (typeof content !== 'string') return [];
112 const parsed = JSON.parse(content) as unknown;
113 return Array.isArray(parsed) ? (parsed as LessonPattern[]) : [];
114 } catch {
115 return [];
116 }
117}
118
119/**
120 * Load newest three lessons.md files.
121 */
122async function loadLessonsMds($: Hook$, home: string): Promise<LessonBullet[]> {
123 const hqBase = joinPath(home, '.claude', 'hq');
124 const allBullets: LessonBullet[] = [];
125
126 try {
127 // Find floor directories
128 const hqEntries = await $.fs.list(hqBase);
129 if (!hqEntries) return [];
130
131 const floorDirs = hqEntries
132 .filter(e => e.kind === 'dir' && e.name.startsWith('floor-'))
133 .map(e => joinPath(hqBase, e.name));
134
135 // Collect lessons.md files with their mtime
136 const lessonsFiles: { path: string; mtime: number }[] = [];
137 for (const dir of floorDirs) {
138 const lessonsPath = joinPath(dir, 'lessons.md');
139 try {
140 const stat = await $.fs.stat(lessonsPath);
141 if (stat && stat.kind === 'file') {
142 lessonsFiles.push({ path: lessonsPath, mtime: stat.mtimeMs });
143 }
144 } catch {
145 // File doesn't exist, skip
146 }
147 }
148
149 // Sort by mtime, newest first, take top 3
150 lessonsFiles.sort((a, b) => b.mtime - a.mtime);
151 const newest = lessonsFiles.slice(0, 3);
152
153 for (const { path } of newest) {
154 try {
155 const content = await $.fs.read(path);
156 if (typeof content === 'string') {
157 allBullets.push(...parseLessonsMd(content));
158 }
159 } catch {
160 // Skip files we can't read
161 }
162 }
163 } catch {
164 // hq directory doesn't exist
165 }
166
167 return allBullets;
168}
169
170/**
171 * Load the full corpus at session.start.
172 */
173async function loadCorpus($: Hook$): Promise<Corpus> {
174 // A mod has no process.env: HOME comes from $.env (types/claude-code.d.ts `$.env.get("HOME")`).
175 // On Windows HOME is normally unset and USERPROFILE set, so read it second through the
176 // same env reader. Never read the process global: a mod worker does not have it.
177 const home =
178 (await $.env.get('HOME').catch(() => undefined)) ||
179 (await $.env.get('USERPROFILE').catch(() => undefined)) ||
180 '';
181 const startTime = Date.now();
182
183 // No home means every corpus path would be relative and resolve under the session's cwd.
184 if (!home) {
185 return { patterns: [], bullets: [], loadedAt: startTime, loadError: 'HOME and USERPROFILE are not set' };
186 }
187
188 try {
189 const hqExtPath = await findNewestHqExt($, home);
190
191 const [patterns, bullets] = await Promise.all([
192 hqExtPath ? loadPatterns($, hqExtPath) : Promise.resolve([] as LessonPattern[]),
193 loadLessonsMds($, home),
194 ]);
195
196 return {
197 patterns,
198 bullets,
199 loadedAt: Date.now(),
200 };
201 } catch (err) {
202 return {
203 patterns: [],
204 bullets: [],
205 loadedAt: startTime,
206 loadError: err instanceof Error ? err.message : 'Unknown error',
207 };
208 }
209}
210
211/**
212 * Match one tool call against the corpus: Bash by command, Edit and Write by
213 * file path and new content. Pure over its inputs; the hook owns all I/O.
214 */
215function matchToolCall(e: ToolCallEvent, loaded: Corpus): MatchedLesson[] {
216 const args: Record<string, unknown> = e;
217 if (e.tool === 'Bash') {
218 const command = String(args.command || '');
219 return command ? matchAll(command, loaded.patterns, loaded.bullets) : [];
220 }
221 if (e.tool === 'Edit') {
222 const filePath = String(args.file_path || '');
223 const newContent = String(args.new_string || args.content || '');
224 return filePath ? matchFileEdit(filePath, newContent, loaded.patterns) : [];
225 }
226 if (e.tool === 'Write') {
227 const filePath = String(args.file_path || '');
228 const content = String(args.content || '');
229 return filePath ? matchFileEdit(filePath, content, loaded.patterns) : [];
230 }
231 return [];
232}
233
234/**
235 * Register the lesson-cards hooks.
236 */
237export function register(on: (event: string, matcherOrHook: unknown, hook?: unknown) => void, _options?: unknown): void {
238 on('session.start', async ($: Hook$, e: SessionStartEvent, next: NextFn<SessionStartEvent>) => {
239 // Register first: CC skips the whole hook when session.start throws, so a
240 // corpus load failure must never take the /lessons command down with it
241 // (measured on a Windows screen 2026-10-05: "Unknown command: /lessons").
242 await $.command.register({ name: 'lessons', description: 'Reload the lesson cards corpus' });
243 interactive = e.isInteractive === true;
244 matchMap.clear();
245 corpus = await loadCorpus($).catch((err: unknown) => ({
246 patterns: [],
247 bullets: [],
248 loadedAt: Date.now(),
249 loadError: err instanceof Error ? err.message : 'Unknown error',
250 }));
251 return next(e);
252 });
253
254 on('tool.call', async ($: Hook$, e: ToolCallEvent, next?: NextFn<ToolCallEvent>) => {
255 const requestId = e.tool_use_id || `tool-${Date.now()}`;
256
257 if (!corpus) {
258 return next ? ((await next(e)) ?? {}) : {};
259 }
260
261 // Match BEFORE the tool runs, so a block lesson can ask first.
262 const matches = matchToolCall(e, corpus);
263 if (matches.length === 0) {
264 return next ? ((await next(e)) ?? {}) : {};
265 }
266
267 // Store matches for ui.render: the card draws under this tool row.
268 matchMap.set(requestId, matches);
269
270 // Try to show notice during permission dialog (only works if dialog is open)
271 try {
272 await $.ui.notice(requestId, `lesson: ${matches[0].id}`);
273 } catch {
274 // Notice refused - dialog not open, ignore
275 }
276
277 const lesson = matches[0];
278 const context = formatContext(lesson);
279
280 // A block lesson asks the human before the call runs, and only an explicit
281 // "Proceed anyway" runs it. Cancel, Escape (the dialog throws "dismissed"),
282 // a typed free-text answer, and no dialog at all (headless -p) all deny:
283 // a block lesson fails closed (estate-6 HOLD on #4429).
284 if (lesson.severity === 'block' && lesson.source === 'pattern') {
285 // $.ui.ask is only ever called: CC's validator refuses reading it as a value.
286 let answer: unknown;
287 let outcome: 'answered' | 'dismissed' | 'no-dialog' = 'no-dialog';
288 if (interactive) {
289 try {
290 answer = await $.ui.ask(askQuestion(lesson), [PROCEED, CANCEL]);
291 outcome = 'answered';
292 } catch (err) {
293 // Escape throws "$.ui.ask: no answer (<rejection text>)". Any other
294 // throw (no ask, a refused dialog) is not the user cancelling.
295 const message = err instanceof Error ? err.message : '';
296 outcome = DISMISSED.some((tail) => message.includes(tail)) ? 'dismissed' : 'no-dialog';
297 }
298 }
299 if (answer !== PROCEED) {
300 // { deny } is the tool.call refusal CC 2.1.282 reads: the call is not
301 // run and the model sees the reason as a permission denial. A bare
302 // { result: string } is refused for Bash (its output is an object).
303 const why = outcome === 'no-dialog'
304 ? 'Not run: no dialog to confirm.'
305 : outcome === 'dismissed' || answer === CANCEL
306 ? 'Cancelled: by you; not run.'
307 : 'Not run: no "Proceed anyway".';
308 return {
309 deny: denyLine(lesson, why),
310 };
311 }
312 }
313
314 const result = next ? ((await next(e)) ?? {}) : {};
315 return { ...(result as Record<string, unknown>), context: [context] };
316 });
317
318 // Wrap, never mutate: next(e) hands back an opaque engine node ({ type, ref }),
319 // so the card goes beside it in a new column Box built from $.ui.resolve(e).
320 on('ui.render', { component: 'ToolUse' }, async ($: Hook$, e: UiRenderEvent, next?: NextFn<UiRenderEvent>) => {
321 const tree = next ? await next(e) : null;
322
323 if (e.component !== 'ToolUse' || !e.requestId) {
324 return tree;
325 }
326
327 const matches = matchMap.get(e.requestId);
328 if (!matches || matches.length === 0) {
329 return tree;
330 }
331
332 const el = await $.ui.resolve(e);
333 const card = buildCard(matches[0], e.requestId, el);
334 return el.Box({ flexDirection: 'column', children: tree ? [tree, card] : [card] });
335 });
336
337 on('command.run', { command: 'lessons' }, async ($: Hook$) => {
338 corpus = await loadCorpus($).catch((err: unknown) => ({
339 patterns: [],
340 bullets: [],
341 loadedAt: Date.now(),
342 loadError: err instanceof Error ? err.message : 'Unknown error',
343 }));
344 matchMap.clear();
345 try {
346 await $.ui.invalidate('ui.render');
347 } catch {
348 // nothing drawn yet: nothing to refresh
349 }
350 const n = corpus ? corpus.patterns.length + corpus.bullets.length : 0;
351 if (n === 0) {
352 const reason = corpus?.loadError ? `: ${corpus.loadError}` : '';
353 return {
354 text: `Lessons reloaded (0 entries)${reason}. Lessons come from configs/lesson-patterns.json in the newest hq-ext cache and ~/.claude/hq/floor-*/lessons.md.`,
355 };
356 }
357 return { text: `Lessons reloaded (${n} entries)` };
358 });
359}
360src/corpus.ts 89 lines1/**
2 * Corpus types and parser for lesson-cards.
3 *
4 * The corpus is assembled from:
5 * - configs/lesson-patterns.json from newest hq-ext under cache
6 * - ~/.claude/hq/floor-N/lessons.md (newest three floor dirs)
7 *
8 * This module is pure: types, path helpers and the lessons.md parser.
9 * The fs reads happen in the hooks module (register.ts) at session.start
10 * or on /lessons reload, never inside tool.call.
11 */
12
13/** Slash-joined path segments; sufficient for read-only fs access on all platforms. */
14export function joinPath(...parts: string[]): string {
15 return parts.filter(p => p.length > 0).join('/');
16}
17
18/** One entry from hq-ext's configs/lesson-patterns.json */
19export interface LessonPattern {
20 id: string;
21 severity: 'block' | 'warn';
22 category: string;
23 pattern?: string;
24 trigger_pattern?: string;
25 block_pattern?: string;
26 check_patterns?: string[];
27 require_pattern?: string;
28 file_glob?: string;
29 file_match?: string;
30 exclude_paths?: string[];
31 tool_names?: string[];
32 message: string;
33 why?: string;
34 example_fix?: string;
35 window_lines?: number;
36 repos?: string[];
37}
38
39/** One bullet parsed from lessons.md */
40export interface LessonBullet {
41 heading: string;
42 text: string;
43 tokens: string[]; // First N tokens for indexing
44}
45
46/** In-memory corpus loaded at session.start */
47export interface Corpus {
48 patterns: LessonPattern[];
49 bullets: LessonBullet[];
50 loadedAt: number;
51 loadError?: string;
52}
53
54/**
55 * Parse a lessons.md file into indexed bullets.
56 *
57 * Format:
58 * ## heading
59 * - bullet text
60 * - bullet text
61 */
62export function parseLessonsMd(content: string): LessonBullet[] {
63 const bullets: LessonBullet[] = [];
64 let currentHeading = '';
65
66 for (const line of content.split('\n')) {
67 const headingMatch = /^##\s+(.+)$/.exec(line);
68 if (headingMatch) {
69 currentHeading = headingMatch[1].trim();
70 continue;
71 }
72
73 const bulletMatch = /^-\s+(.+)$/.exec(line);
74 if (bulletMatch && currentHeading) {
75 const text = bulletMatch[1].trim();
76 // Extract first few command tokens for indexing
77 // Tokens like 'gh', 'git', 'pnpm', 'herdr', 'vm_stat', etc.
78 const tokens = text.split(/\s+/).slice(0, 3).filter(t => /^[a-z_-]/i.test(t));
79 bullets.push({
80 heading: currentHeading,
81 text,
82 tokens,
83 });
84 }
85 }
86
87 return bullets;
88}
89src/match.ts 428 lines1/**
2 * Pattern matcher for lesson-cards.
3 *
4 * Pure matching logic:
5 * - For Bash: blank heredoc bodies and quoted strings first, then run pattern table
6 * - For Edit/Write: match file_path against file_glob, content against check_patterns
7 *
8 * No external dependencies - uses simple glob matching.
9 */
10
11import type { LessonPattern, LessonBullet } from './corpus.js';
12import type { MatchedLesson } from './types.js';
13
14/**
15 * Simple glob pattern matching.
16 * Supports:
17 * - * matches any sequence of characters (except /)
18 * - ** matches any sequence including /
19 * - ? matches single character
20 * - literal characters match themselves
21 */
22function simpleGlobMatch(str: string, pattern: string): boolean {
23 // Normalize pattern and string
24 const normalizedStr = str.replace(/\\/g, '/');
25 const normalizedPattern = pattern.replace(/\\/g, '/');
26
27 // Split pattern into segments
28 const patternParts = normalizedPattern.split('/');
29 const strParts = normalizedStr.split('/');
30
31 let patternIdx = 0;
32 let strIdx = 0;
33
34 while (patternIdx < patternParts.length && strIdx < strParts.length) {
35 const patPart = patternParts[patternIdx];
36 const strPart = strParts[strIdx];
37
38 if (patPart === '**') {
39 // ** matches zero or more path segments
40 patternIdx++;
41
42 // If this is the last pattern part, it matches everything remaining
43 if (patternIdx >= patternParts.length) {
44 return true;
45 }
46
47 // Try to match the remaining pattern at each position
48 while (strIdx <= strParts.length) {
49 const remainingStr = strParts.slice(strIdx).join('/');
50 const remainingPat = patternParts.slice(patternIdx).join('/');
51 if (simpleGlobMatch(remainingStr, remainingPat)) {
52 return true;
53 }
54 strIdx++;
55 }
56 return false;
57 } else {
58 // Match single segment
59 if (!segmentMatch(strPart, patPart)) {
60 return false;
61 }
62 patternIdx++;
63 strIdx++;
64 }
65 }
66
67 // Handle trailing ** that can match zero segments
68 while (patternIdx < patternParts.length && patternParts[patternIdx] === '**') {
69 patternIdx++;
70 }
71
72 return patternIdx === patternParts.length && strIdx === strParts.length;
73}
74
75/**
76 * Match a single path segment against a pattern.
77 */
78function segmentMatch(segment: string, pattern: string): boolean {
79 if (pattern === '*') {
80 return true;
81 }
82
83 // Simple pattern matching for single segment
84 let si = 0;
85 let pi = 0;
86
87 while (si < segment.length && pi < pattern.length) {
88 const pc = pattern[pi];
89
90 if (pc === '*') {
91 // * matches zero or more characters
92 pi++;
93 // Skip * and try to match rest
94 while (si <= segment.length) {
95 if (segmentMatch(segment.slice(si), pattern.slice(pi))) {
96 return true;
97 }
98 si++;
99 }
100 return false;
101 } else if (pc === '?') {
102 // ? matches exactly one character
103 si++;
104 pi++;
105 } else if (segment[si] === pc) {
106 si++;
107 pi++;
108 } else {
109 return false;
110 }
111 }
112
113 // Skip remaining * in pattern
114 while (pi < pattern.length && pattern[pi] === '*') {
115 pi++;
116 }
117
118 return si === segment.length && pi === pattern.length;
119}
120
121/**
122 * Blank out quoted strings for shell-faithful matching.
123 * Prevents patterns from matching content inside quotes.
124 */
125export function blankQuotedContent(cmd: string): string {
126 // Single-quoted strings: no escaping inside
127 let result = cmd.replace(/'[^']*'/g, "''");
128 // Double-quoted strings: handle escaped quotes
129 result = result.replace(/"(?:[^"\\]|\\.)*"/g, '""');
130 return result;
131}
132
133/**
134 * Blank out quoted heredoc bodies.
135 * Quoted heredoc delimiters (<<'EOF') disable expansion in the body.
136 */
137export function blankQuotedHeredocBodies(cmd: string): string {
138 // <<'DELIM' or <<"DELIM" followed by body until DELIM on its own line
139 return cmd.replace(
140 /(<<-?\s*(['"])([A-Za-z_][A-Za-z0-9_]{0,63})\2)([\s\S]{0,20000}?)(^[ \t]*\3[ \t]*$)/gm,
141 (_m, open: string, _q: string, _delim: string, _body: string, close: string) =>
142 `${open}\n${close}`,
143 );
144}
145
146/**
147 * Check if a repo is allowed by the pattern's repos filter.
148 */
149function repoAllowed(pattern: LessonPattern, currentRepo?: string): boolean {
150 if (!pattern.repos || pattern.repos.length === 0) {
151 return true; // No repo filter, matches all
152 }
153 if (!currentRepo) {
154 return false; // Pattern has repos filter but we don't know current repo
155 }
156 return pattern.repos.some(r => {
157 // Support owner/repo format
158 if (r.includes('/')) {
159 return currentRepo === r || simpleGlobMatch(currentRepo, r);
160 }
161 // Just repo name
162 return currentRepo.endsWith('/' + r) || currentRepo === r;
163 });
164}
165
166/**
167 * Check if a path is excluded by the pattern's exclude_paths.
168 */
169function pathExcluded(pattern: LessonPattern, filePath: string): boolean {
170 if (!pattern.exclude_paths || pattern.exclude_paths.length === 0) {
171 return false;
172 }
173 return pattern.exclude_paths.some(ex => simpleGlobMatch(filePath, ex));
174}
175
176/**
177 * Match a Bash command against patterns.
178 *
179 * Steps:
180 * 1. Blank quoted content and heredoc bodies for shell-faithful matching
181 * 2. Check tool_names filter if present
182 * 3. Check trigger_pattern or pattern
183 * 4. If block_pattern is set, must also match that (for guard patterns)
184 */
185export function matchBashCommand(
186 command: string,
187 patterns: LessonPattern[],
188 currentRepo?: string
189): MatchedLesson[] {
190 const matches: MatchedLesson[] = [];
191
192 // Blank quoted heredoc bodies first (before quoted strings break delimiter detection)
193 // then blank quoted strings for shell-faithful matching
194 const blankedCommand = blankQuotedContent(blankQuotedHeredocBodies(command));
195 const targetText = blankedCommand.toLowerCase();
196
197 for (const pat of patterns) {
198 // Check tool_names filter
199 if (pat.tool_names && !pat.tool_names.includes('Bash')) {
200 continue;
201 }
202
203 // Check repo filter
204 if (!repoAllowed(pat, currentRepo)) {
205 continue;
206 }
207
208 // Check trigger_pattern or pattern
209 const triggerRegex = pat.trigger_pattern || pat.pattern;
210 if (!triggerRegex) {
211 continue;
212 }
213
214 // Create regex from pattern string
215 let regex: RegExp;
216 try {
217 regex = new RegExp(triggerRegex, 'i');
218 } catch {
219 // Invalid regex, skip
220 continue;
221 }
222
223 if (!regex.test(targetText)) {
224 continue;
225 }
226
227 // If block_pattern is set, this is a guard pattern that needs both conditions
228 if (pat.block_pattern) {
229 let blockRegex: RegExp;
230 try {
231 blockRegex = new RegExp(pat.block_pattern, 'i');
232 } catch {
233 continue;
234 }
235 if (!blockRegex.test(targetText)) {
236 continue;
237 }
238 }
239
240 matches.push({
241 id: pat.id,
242 severity: pat.severity,
243 message: pat.message,
244 fix: pat.example_fix,
245 source: 'pattern',
246 });
247 }
248
249 return matches;
250}
251
252/**
253 * Match Edit/Write args against patterns.
254 *
255 * Checks file_path against file_glob and new content against check_patterns.
256 */
257export function matchFileEdit(
258 filePath: string,
259 content: string,
260 patterns: LessonPattern[],
261 currentRepo?: string
262): MatchedLesson[] {
263 const matches: MatchedLesson[] = [];
264
265 for (const pat of patterns) {
266 // Check tool_names filter
267 if (pat.tool_names && !pat.tool_names.includes('Edit') && !pat.tool_names.includes('Write')) {
268 continue;
269 }
270
271 // Check repo filter
272 if (!repoAllowed(pat, currentRepo)) {
273 continue;
274 }
275
276 // Check file_glob filter
277 if (pat.file_glob) {
278 if (!simpleGlobMatch(filePath, pat.file_glob)) {
279 continue;
280 }
281 }
282
283 // Check exclude_paths
284 if (pathExcluded(pat, filePath)) {
285 continue;
286 }
287
288 // Check file_match pattern if present
289 if (pat.file_match) {
290 let fileMatchRegex: RegExp;
291 try {
292 fileMatchRegex = new RegExp(pat.file_match, 'i');
293 } catch {
294 continue;
295 }
296 if (!fileMatchRegex.test(filePath)) {
297 continue;
298 }
299 }
300
301 // Check check_patterns against content
302 if (pat.check_patterns && pat.check_patterns.length > 0) {
303 let matchedAny = false;
304 for (const checkPat of pat.check_patterns) {
305 let checkRegex: RegExp;
306 try {
307 checkRegex = new RegExp(checkPat, 'i');
308 } catch {
309 continue;
310 }
311 if (checkRegex.test(content)) {
312 matchedAny = true;
313 break;
314 }
315 }
316 if (!matchedAny) {
317 continue;
318 }
319 } else if (!pat.file_glob && !pat.file_match) {
320 // No file filter and no check_patterns, skip
321 continue;
322 }
323
324 matches.push({
325 id: pat.id,
326 severity: pat.severity,
327 message: pat.message,
328 fix: pat.example_fix,
329 source: 'pattern',
330 });
331 }
332
333 // Block before warn (stable within a severity): callers act on matches[0],
334 // so corpus order must never let a warn hide a block (estate-6 HOLD on #4429).
335 return bySeverity(matches);
336}
337
338/** Severity rank: block first. */
339const SEVERITY_RANK: Record<MatchedLesson['severity'], number> = { block: 0, warn: 1 };
340
341/** Stable sort of matches, block before warn. */
342export function bySeverity(matches: MatchedLesson[]): MatchedLesson[] {
343 return matches
344 .map((m, i) => ({ m, i }))
345 .sort((a, b) => SEVERITY_RANK[a.m.severity] - SEVERITY_RANK[b.m.severity] || a.i - b.i)
346 .map(({ m }) => m);
347}
348
349/**
350 * Match a Bash command against lessons.md bullets.
351 *
352 * Bullets are indexed by their first few command tokens.
353 * Returns at most one bullet match (advisory, grey).
354 */
355export function matchLessonsBullet(
356 command: string,
357 bullets: LessonBullet[]
358): MatchedLesson | null {
359 const tokens = command.trim().split(/\s+/).slice(0, 3);
360 if (tokens.length === 0) {
361 return null;
362 }
363
364 // Find bullet whose tokens match our command tokens
365 for (const bullet of bullets) {
366 const bulletTokens = bullet.tokens;
367 if (bulletTokens.length === 0) {
368 continue;
369 }
370
371 // Check if command starts with bullet's indexed tokens
372 let matches = true;
373 for (let i = 0; i < bulletTokens.length && i < tokens.length; i++) {
374 if (tokens[i].toLowerCase() !== bulletTokens[i].toLowerCase()) {
375 matches = false;
376 break;
377 }
378 }
379
380 if (matches) {
381 return {
382 id: bullet.heading.replace(/\s+/g, '-').toLowerCase(),
383 severity: 'warn', // Advisory, grey
384 message: bullet.text,
385 source: 'bullet',
386 };
387 }
388 }
389
390 return null;
391}
392
393/**
394 * Combine pattern matches with bullet matches.
395 * Guard pattern match takes priority, capped at one card per call.
396 */
397export function matchAll(
398 command: string,
399 patterns: LessonPattern[],
400 bullets: LessonBullet[],
401 currentRepo?: string
402): MatchedLesson[] {
403 const patternMatches = matchBashCommand(command, patterns, currentRepo);
404
405 // Guard patterns (severity 'block') take priority
406 const blockMatches = patternMatches.filter(m => m.severity === 'block');
407 if (blockMatches.length > 0) {
408 return [blockMatches[0]]; // Cap at one
409 }
410
411 // Warn matches are additive
412 const warnMatches = patternMatches.filter(m => m.severity === 'warn');
413
414 // Bullet matches are advisory (grey)
415 const bulletMatch = matchLessonsBullet(command, bullets);
416
417 // Combine: warn first, then bullet if no warn
418 if (warnMatches.length > 0) {
419 return [warnMatches[0]];
420 }
421
422 if (bulletMatch) {
423 return [bulletMatch];
424 }
425
426 return [];
427}
428src/card.ts 162 lines1/**
2 * UI tree builder for lesson cards.
3 *
4 * Creates a Box containing the lesson card that renders under a ToolUse row.
5 * The element constructors come from $.ui.resolve(e) in the hooks module: on
6 * CC 2.1.282 a render hook may only return nodes built by those constructors.
7 * A plain { type: 'Box' } object is refused as "not an element" and draws
8 * nothing, which is why the card never showed before (measured 2026-09-25).
9 */
10
11import type { MatchedLesson } from './types.js';
12
13/** One element constructor, as handed out by $.ui.resolve(e). */
14export type ElementCtor = (props?: Record<string, unknown>) => unknown;
15
16/** The subset of the resolved element table the card uses. */
17export interface CardElements {
18 Box: ElementCtor;
19 Text: ElementCtor;
20}
21
22/** Border and title color per severity: block red, warn yellow, bullet gray. */
23export function cardColor(lesson: MatchedLesson): 'red' | 'yellow' | 'gray' {
24 if (lesson.source === 'bullet') return 'gray';
25 return lesson.severity === 'block' ? 'red' : 'yellow';
26}
27
28/**
29 * Build a lesson card from resolved elements.
30 *
31 * Structure:
32 * - Round border box in the severity color
33 * - Title row: "lesson: <id>" (bold, colored) and the severity word
34 * - Message row
35 * - Fix row (dim, if available)
36 */
37export function buildCard(lesson: MatchedLesson, requestId: string, el: CardElements): unknown {
38 const color = cardColor(lesson);
39 const label = lesson.source === 'bullet' ? 'note' : lesson.severity;
40
41 const rows: unknown[] = [
42 el.Box({
43 children: [
44 el.Text({ bold: true, color, children: `lesson: ${lesson.id}` }),
45 el.Text({ dimColor: true, children: ` ${label}` }),
46 ],
47 }),
48 el.Text({ children: lesson.message }),
49 ];
50
51 if (lesson.fix) {
52 rows.push(el.Text({ dimColor: true, children: `Fix: ${lesson.fix}` }));
53 }
54
55 return el.Box({
56 key: `lesson-${requestId}`,
57 flexDirection: 'column',
58 borderStyle: 'round',
59 borderColor: color,
60 paddingX: 1,
61 children: rows,
62 });
63}
64
65/** Longest deny line, in code points; the card above it shows the full lesson and fix. */
66export const MAX_DENY_CHARS = 240;
67
68/** Longest short fix inside the deny line, in code points. */
69export const MAX_FIX_CHARS = 110;
70
71/** Cut to at most max code points (Array.from never splits a surrogate pair), marking a cut with "...". */
72export function cutCodePoints(text: string, max: number): string {
73 const points = Array.from(text);
74 return points.length > max ? `${points.slice(0, max - 3).join('')}...` : text;
75}
76
77const COMMENT = /^\s*(#|\/\/)/;
78const RIGHT_HEADING = /\b(RIGHT|BEST|FIX|DO)\b/i;
79const WRONG_HEADING = /\b(WRONG|BAD|DON'?T|DO NOT|NEVER|AVOID)\b/i;
80
81/**
82 * The one line of a lesson's fix worth sending with a refusal. Fixes are
83 * usually WRONG / RIGHT example blocks, so prefer the first code line under a
84 * RIGHT or BEST heading; else the first code line outside a WRONG section;
85 * else the first non-empty line. Undefined when the lesson has no fix.
86 */
87export function shortFix(fix: string | undefined): string | undefined {
88 if (!fix) return undefined;
89 const lines = fix.split('\n').map(l => l.trim()).filter(l => l.length > 0);
90 let section: 'right' | 'wrong' | 'none' = 'none';
91 let firstNeutral: string | undefined;
92 for (const line of lines) {
93 if (COMMENT.test(line)) {
94 // A neutral comment ends a WRONG block ("use this instead").
95 // WRONG is tested first: "# WRONG: Do not use this" also contains "Do".
96 section = WRONG_HEADING.test(line) ? 'wrong' : RIGHT_HEADING.test(line) ? 'right' : 'none';
97 continue;
98 }
99 if (section === 'right') return cutCodePoints(line, MAX_FIX_CHARS);
100 if (section === 'none' && firstNeutral === undefined) firstNeutral = line;
101 }
102 // Never offer a WRONG example as the fix: with no code outside a WRONG
103 // block there is no short fix.
104 const sawHeading = lines.some(l => COMMENT.test(l));
105 const pick = firstNeutral ?? (sawHeading ? undefined : lines[0]);
106 return pick === undefined ? undefined : cutCodePoints(pick.replace(/\s+/g, ' '), MAX_FIX_CHARS);
107}
108
109/**
110 * The block-lesson question: the lesson id and "Proceed anyway?" only.
111 * The card above the dialog already shows the lesson; repeating the message
112 * here drew the same paragraph twice on screen.
113 */
114export function askQuestion(lesson: MatchedLesson): string {
115 return `lesson ${lesson.id}: Proceed anyway?`;
116}
117
118/**
119 * True when why is a user cancel (Cancel button or Escape). Those omit Fix.
120 * The deny starts with "Cancelled: " so Claude Code's 2.1.283 renderer leaves
121 * it alone (same pass-through as "Error: "); the rest is "by you; not run."
122 * so Cancelled is said once (not "Cancelled: Cancelled by you").
123 */
124export function isUserCancel(why: string): boolean {
125 return (
126 why === 'Cancelled: by you; not run.' ||
127 why.startsWith('Cancelled: ') ||
128 why === 'by you; not run.' ||
129 why === 'Cancelled by you; not run.'
130 );
131}
132
133/**
134 * The one-line reason a refused call carries: why, the lesson id, and (for a
135 * real block, not a user cancel) a short fix. A cancel uses
136 * "Cancelled: by you; not run." so the Cancelled: head avoids Error: glue and
137 * Fix is omitted.
138 */
139export function denyLine(lesson: MatchedLesson, why: string): string {
140 const fix = isUserCancel(why) ? undefined : shortFix(lesson.fix);
141 const tail = fix ? ` Fix: ${fix}` : '';
142 return cutCodePoints(`${why} [lesson:${lesson.id}]${tail}`, MAX_DENY_CHARS);
143}
144
145/**
146 * Format lesson context for model consumption.
147 *
148 * Returns the message and fix (if any), capped at a reasonable length.
149 * This is added to the tool.call return context array.
150 */
151export function formatContext(lesson: MatchedLesson, maxLength = 32000): string {
152 let text = `[lesson:${lesson.id}] ${lesson.message}`;
153 if (lesson.fix) {
154 text += ` Fix: ${lesson.fix}`;
155 }
156 // Cap at maxLength to avoid context bloat
157 if (text.length > maxLength) {
158 text = text.slice(0, maxLength - 3) + '...';
159 }
160 return text;
161}
162src/types.ts 58 lines1/**
2 * Type definitions for lesson-cards.
3 *
4 * Minimal $ facade for testing with a fake implementation.
5 */
6
7export interface FsEntry {
8 name: string;
9 isDirectory: boolean;
10 isFile: boolean;
11}
12
13export interface FileStat {
14 size: number;
15 mtime?: number;
16 isFile: boolean;
17 isDirectory: boolean;
18}
19
20export interface $ {
21 fs: {
22 read: (path: string, encoding?: string) => Promise<string | Uint8Array>;
23 list: (path: string) => Promise<FsEntry[] | null>;
24 stat: (path: string) => Promise<FileStat | null>;
25 };
26 ui: {
27 notice: (toolUseId: string, message: string) => Promise<void>;
28 invalidate: (component: string) => Promise<void>;
29 };
30}
31
32export interface ToolCallEvent {
33 tool: string;
34 args: Record<string, unknown>;
35 tool_use_id?: string;
36}
37
38export interface UiRenderEvent {
39 component: string;
40 requestId?: string;
41}
42
43export interface SessionStartEvent {
44 // Empty payload for session.start
45}
46
47export interface CommandRegisterEvent {
48 command: string;
49}
50
51export interface MatchedLesson {
52 id: string;
53 severity: 'block' | 'warn';
54 message: string;
55 fix?: string;
56 source: 'pattern' | 'bullet';
57}
58