SLOPSHOPPER

lesson-cards

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

newrowsguardcommand
★ 291v1.0.8MITupdated 2026-10-06yonatangross/orchestkit/mods/lesson-cards
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · lesson-cards
› 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 › /lessons ⎿ lesson-cards: Lessons reloaded (0 entries). Lessons come from configs/lesson-patterns.json in the newest hq-ext cache and ~/ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

lesson-cards

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

Minimum CC version

Requires Claude Code 2.1.266 or later (first measured classic.* binary).

Installation

  1. Copy this folder to your project: ``bash cp -r mods/lesson-cards/ /path/to/your/project/mods/ ``
  1. On CC 2.1.266 through 2.1.286, enable function hooks in your shell profile or personal settings (not needed on 2.1.287+, where Mods are public): ``bash export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ``

Or in ~/.claude/settings.json:

   {
     "env": {
       "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
     }
   }
  1. Start Claude Code with the plugin directory: ``bash claude --plugin-dir mods/ ``

Features

  • Pattern matching: Matches Bash commands and Edit/Write operations against known lesson patterns from hq-ext
  • Lessons.md indexing: Parses bullet points from ~/.claude/hq/floor-*/lessons.md and indexes by command tokens
  • Hint cards: Displays a bordered card under the tool row with lesson id, severity, message, and fix suggestion (red for block, yellow for warn, gray for a lessons.md note)
  • Proceed anyway?: A block-severity pattern opens the Claude Code question dialog ($.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 warn
  • Model context: Adds matched lessons to the tool result context for the model to read
  • Additive: During pilot, the classic pretool-lesson-guard --strict keeps its 12 block-severity denies unchanged

Hook footprint

EventMatcherNotes
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

Calls

  • $.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 reload

Negative pin (what it does NOT do)

  • No process.run
  • No http.fetch
  • No I/O inside tool.call (all reads happen at session.start)

Rollback

  1. Remove from plugin directory or disable: ``bash rm -rf mods/lesson-cards # or claude plugin disable lesson-cards ``
  1. On 2.1.266 to 2.1.286, unset the flag (inert on 2.1.287+): ``bash unset CLAUDE_CODE_ENABLE_FUNCTION_HOOKS ``

No state persisted except the in-memory match map, which is safe to drop.

Commands

  • /lessons - Reload corpus from hq-ext cache and lessons.md files

Seeing a card in a live session

lesson-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):

  • Warn card: echo pgvector/pgvector:pg16 matches pgvector-pg-version-mismatch and draws a yellow card under the row.
  • Block card and question: 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:

  1. Floor lessons bullet: Run an indexed command from ~/.claude/hq/floor-*/lessons.md, such as vm_stat. The hq-ext guard does not inspect floor bullets, allowing the command to run and display an advisory hint card under the tool row.
  2. Warn pattern: Run a command matching a warn-severity pattern, such as alembic stamp head. The hq-ext guard exits 0 for warn-severity patterns, allowing the command to proceed while lesson-cards renders the card and notice.

Acceptance checklist

  • ☐ Pattern matching works for Bash commands
  • ☐ Pattern matching works for Edit/Write operations
  • ☐ Cards render under ToolUse rows
  • ☐ Context is added to tool results
  • ☐ /lessons command reloads corpus
  • ☐ Classic guard still denies block-severity patterns
  • ☐ Corpus load under 50ms
  • ☐ No I/O on hot path

Risk

Low. Purely additive during pilot; the only behavioral change is one extra context line per matched call.

Source 5 files
hooks/register.ts 360 lines
1/**
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}
360
src/corpus.ts 89 lines
1/**
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}
89
src/match.ts 428 lines
1/**
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}
428
src/card.ts 162 lines
1/**
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}
162
src/types.ts 58 lines
1/**
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