SLOPSHOPPER

Sensei

Learn anything in Claude Code with a plan you approve first and a check you pass at every step. Looks facts up on the web before it teaches.

panebandcommandtoasttimer
v1.0.7Apache-2.0updated 2026-10-17rishichawda/sensei
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sensei
│ ┃ Map ✕ › fix the failing auth test and add an audit log call │ ┃ No plan yet. Ask Claude to teach you │ ┃ something and it shows a plan for you to ⏺ Read(src/auth.ts) │ ┃ approve first. ⎿ 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 │ │ › /map │ ⎿ sensei: Opened your learning map. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Map
No plan yet. Ask Claude to teach you something and it shows a plan for you to approve first.
README

<h1 align="center">sensei</h1>

<a href="https://github.com/rishichawda/sensei/actions/workflows/ci.yml"><img src="https://github.com/rishichawda/sensei/actions/workflows/ci.yml/badge.svg" alt="CI"></a> <a href="https://github.com/rishichawda/sensei/releases"><img src="https://img.shields.io/github/v/release/rishichawda/sensei" alt="Release"></a> <a href="https://github.com/rishichawda/sensei/blob/main/LICENSE"><img src="https://img.shields.io/github/license/rishichawda/sensei" alt="License"></a>

<!-- Demo GIF goes here. -->

An AI explanation can be very clear as you're reading it, and then be completely gone by the next day. Sensei is built around two habits that combat that.

You see the path before you walk it. Before teaching sensei asks what you want from the topic, assesses your current level with a few graded questions and then draws up the plan as a dependency map showing what ideas build on what. You either approve the map or make changes to it. It then teaches in that order so nothing is explained before the idea it depends on.

You prove each step as you go. After each chunk of the lesson the Sensei will ask a short question. A hook will grade your answer and record it so the outcome is not dependent on what the model says happened. A correct answer sees you move on. A miss gets you a fresh explanation and another go.

<table> <tr> <td width="50%"><img src="assets/plan-proposed.png" alt="The proposed plan, shown in the map pane and marked as not approved yet." width="100%"></td> <td width="50%"><img src="assets/check-question.png" alt="A check question after the explanation, with the map pane still open." width="100%"></td> </tr> </table>

Sensei remembers. It saves your progress per topic on your machine so when you come back it picks up where you left off and skips the bits you've already done.

Try it

claude plugin marketplace add https://github.com/rishichawda/sensei
claude plugin install sensei@rishichawda

Start a new session and ask Claude: "Teach me how DNS resolution works".

Other ways to start:

  • "Teach me music theory. I can already read sheet music." Sensei assesses your level first and skips what you already know.
  • "Teach me how a hash map works and draw how a lookup travels through it." The lesson includes a diagram.
  • "Continue teaching me DNS resolution." Sensei picks up the saved session for that topic.

What you get

A map you can open. Type /map to get a pane showing the approved plan with one chip per step: ○ not started, ● in progress, ✓ passed, ✗ missed. Click on a chip to see what it builds on and what it builds to. A line appears above the prompt indicating your current position in the lesson and a toast appears for each pass/miss with the number of questions answered correctly. The map is not drawn in the chat.

Notes in your Obsidian vault. Set vault path via /plugin configure sensei and sensei will save what you learn, one note per concept under sensei/<topic>/. The note will contain the explanation, a flashcard for the Spaced Repetition plugin, your answers and how any mistake was re-explained. A _map.md note contains the plan as a diagram which Obsidian will draw up for you. The path takes effect on your next message. Concept notes are only ever appended to; sensei only rewrites _map.md.

Diagrams when they're useful. A lesson can contain a diagram created by a bundled helper which will check its own output prior to displaying it. Mermaid is used for structure and flow, SVG for geometry and plots. If a diagram can't be generated then the lesson proceeds without it.

Requirements

  • Claude Code 2.1.281 or later. The map pane, the line above the prompt and the toasts need it.
  • Optional: Obsidian and its Spaced Repetition plugin (for the vault notes and flashcards).
  • Sensei is built and tested for Claude Code. In claude.ai chat the lesson and the check questions run, but plugin hooks, agents and the map pane do not load there, so no hook grades or records your answers, no diagram helper runs and there is no map. It has not been tested in Cowork. Use Claude Code for the full experience.

What sensei runs, stores and sends

Hooks. hooks/hooks.json runs Node scripts that ship with the plugin, at session start, on each prompt, around each AskUserQuestion call, and after Bash and Skill calls. During a lesson they grade your answer to each check question and record the result, add a reminder for the model when a question is missing its tag or a verdict, and mirror the lesson to your vault if you set one. On a prompt that contains "teach me", "continue teaching" or "resume teaching" they add a reminder to use the teach skill. The one hook that runs before a call (on AskUserQuestion) can only deny it, to ask for a missing tag; it never approves a call. The hooks read the tool call and result, not the conversation transcript; the transcript's file name is only used to place a small marker file beside it.

Files it writes. Under your Claude config directory (~/.claude, or CLAUDE_CONFIG_DIR), in sensei/: one progress ledger per topic in ledgers/, a file per running lesson in active/, the vault path you set, and two debug logs (mastery-hook-debug.log, vault-debug.log) that keep the last 200 lines each. They hold topic and step names, the lesson's tag data (which can include the label of the correct option) and error messages, not the text of your questions or your answers. Beside the session transcript: two small marker files ending in .teach-active-state.json and .teach-invoked-state.json. In your vault, only if you set one: notes under <vault>/sensei/. In the current project: rendered diagrams as PNG files in viz/. While drawing a Mermaid diagram it also uses a temporary folder, which it deletes afterwards. Sensei writes nowhere else. Delete the sensei/ folder to remove your progress.

What it runs. The diagram helpers run Mermaid CLI and a local copy of Chrome or Chromium to draw Mermaid diagrams, and @resvg/resvg-js to draw SVG, on your machine. Chrome is started with no network access, so a diagram cannot make it request a URL. Claude Code installs these npm packages from the plugin's lockfile when you install the plugin. The mod in hooks/mod/register.tsx registers the /map command, handles only that command, and draws the map pane, the line above the prompt and the toasts from the ledger files, which it reads once a second. It changes no other call.

What it sends. Sensei's own code makes no network requests, has no telemetry and sends nothing to the author. The one thing that leaves your machine is the web lookups. Before it teaches, and whenever it is unsure of a fact, sensei starts the researcher agent, which uses Claude Code's WebSearch and WebFetch tools to search the web and read pages. The model writes the search text, so it can contain the topic you asked about and what you told it you want from the topic. Sensei's code does not add your files, vault notes, progress or answers to a search. The agent treats the pages it reads as data, never as instructions. See PRIVACY.md for the full list and how to delete your data.

Why I built it

One weekend morning, coffee in hand at my desk, I came across a YouTube video about using AI and Obsidian to learn anything. I use Obsidian and love picking up new skills, so I wanted a tutor in the tool I already use, one that keeps what I learn in my own notes. The video linked a project, but that was for pi. So I made one myself as a Claude Code plugin. It plans before it teaches, checks before it moves on, and remembers where I stopped.

Contributing and license

See CONTRIBUTING.md and the code of conduct. Sensei is released under the Apache License 2.0.

Source 4 files
hooks/mod/register.tsx 142 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { MapView } from '../../types'
5import { displayName } from '../vault/graph.mjs'
6import { bandLine, changedStatuses, chipLabel, drawnPlan, goalOf, layers, nodeDetail, progress, proposedPlan, snapshot } from './map-layout.mjs'
7
8const PANE = 'map'
9const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
10const view = atom({ plugin: 'sensei', key: 'view' } as const, { topic: null, ledger: null, selected: null } as MapView)
11
12// The planning glyph's frame; only moves while a lesson is being planned.
13const spin = atom({ plugin: 'sensei', key: 'spin' } as const, 0)
14
15async function configRoot($: any): Promise<string | null> {
16  const dir = await $.env.get('CLAUDE_CONFIG_DIR')
17  if (dir) return dir
18  const home = await $.env.get('HOME')
19  return home ? `${home}/.claude` : null
20}
21
22async function readJson($: any, path: string): Promise<any> {
23  try {
24    return JSON.parse(await $.fs.read(path))
25  } catch {
26    return null
27  }
28}
29
30// The active file is written by the vault hook (keyed by session id); the ledger
31// is the teach skill's own record. Either missing means no lesson is running.
32async function load($: any): Promise<{ topic: string; ledger: any } | null> {
33  const root = await configRoot($)
34  if (!root) return null
35  const id = await $.session.id()
36  const active = await readJson($, `${root}/sensei/active/${id}.json`)
37  if (!active || typeof active.topic !== 'string') return null
38  const ledger = await readJson($, `${root}/sensei/ledgers/${active.topic}.json`)
39  return ledger ? { topic: active.topic, ledger } : null
40}
41
42export const register: Register = on => {
43  on('session.start', async ($, e, next) => {
44    await $.command.register({ name: 'map', description: 'Show your learning map' })
45
46    // `previous` only words toasts; losing it on a reload just skips one toast.
47    // `drawn` is what the view holds: any change to the ledger redraws.
48    let previous: ReturnType<typeof snapshot> = null
49    let drawn: string | undefined
50    let planning = false
51    const refresh = async () => {
52      const loaded = await load($)
53      const raw = JSON.stringify(loaded)
54      if (raw === drawn) return
55      drawn = raw
56      const current = loaded ? snapshot(loaded.topic, loaded.ledger) : null
57      const toast = changedStatuses(previous, current)
58      if (toast) $.ui.toast(toast)
59      previous = current
60      planning = bandLine(loaded?.ledger)?.animated === true
61      await update($, view, v => ({
62        topic: loaded?.topic ?? null,
63        ledger: loaded?.ledger ?? null,
64        selected: loaded && v.topic === loaded.topic ? v.selected : null,
65      }))
66    }
67    await refresh()
68    $.clock.every(1000, refresh)
69    $.clock.every(120, async () => {
70      if (planning) await update($, spin, n => (n + 1) % SPINNER.length)
71    })
72
73    return next(e)
74  })
75
76  on('command.run', { command: 'map' }, async $ => {
77    await $.ui.open({ id: PANE, title: 'Map' })
78    return { text: 'Opened your learning map.' }
79  })
80
81  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
82    const v = await read($, view)
83    const band = bandLine(v.ledger)
84    if (!band) return next(e)
85    const frame = band.animated ? SPINNER[await read($, spin)] : band.glyph
86    const { Text } = $.ui.resolve(e)
87    return (
88      <Text>
89        <Text color={band.tone}>{`${frame} ${band.head}`}</Text>
90        {band.detail && <Text dimColor>{`  ·  ${band.detail}`}</Text>}
91        {band.last && <Text color="green" dimColor>{`  ·  ✓ previously passed: ${band.last}`}</Text>}
92        <Text dimColor>{'  ·  /map'}</Text>
93      </Text>
94    )
95  })
96
97  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
98    const { Box, Text, Button } = $.ui.resolve(e)
99    const v = await read($, view)
100    const ledger = v.ledger as any
101    const map = drawnPlan(ledger)
102    if (!map || map.nodes.length === 0) {
103      const goal = goalOf(ledger)
104      return <Text dimColor>{goal ? `No plan yet · ${goal}. Claude shows one for you to approve first.` : 'No plan yet. Ask Claude to teach you something and it shows a plan for you to approve first.'}</Text>
105    }
106    const isProposal = map === proposedPlan(ledger)
107    const { passed, total } = progress(ledger)
108    const rows = layers(map.nodes, map.edges)
109    const detail = v.selected ? nodeDetail(ledger, v.selected) : null
110
111    return (
112      <Box flexDirection="column" gap={1}>
113        <Box flexDirection="column">
114          {isProposal && <Text bold>Proposed plan — not approved yet</Text>}
115          <Text bold>{goalOf(ledger) ?? displayName(v.topic ?? '')}</Text>
116          <Text dimColor>{isProposal ? `${map.nodes.length} steps` : `${passed} of ${total} passed`}</Text>
117        </Box>
118        {rows.map((row, i) => (
119          <Box key={`row:${i}`} flexDirection="row" flexWrap="wrap" gap={1}>
120            {row.map(id => (
121              <Button
122                key={`node:${id}`}
123                label={chipLabel(ledger, id)}
124                variant={id === v.selected ? 'primary' : 'secondary'}
125                onPress={() => update($, view, s => ({ ...s, selected: id }))}
126              />
127            ))}
128          </Box>
129        ))}
130        {detail && (
131          <Box flexDirection="column">
132            <Text bold>{detail.name}</Text>
133            <Text>{`Status: ${detail.status}${detail.taught ? ` · taught ${detail.taught}` : ''}`}</Text>
134            {detail.prerequisites.length > 0 && <Text>{`Builds on: ${detail.prerequisites.join(', ')}`}</Text>}
135            {detail.dependents.length > 0 && <Text>{`Leads to: ${detail.dependents.join(', ')}`}</Text>}
136          </Box>
137        )}
138      </Box>
139    )
140  })
141}
142
hooks/vault/graph.mjs 21 lines
1// No imports: the Claude Code mod imports this file, and a mod has no Node.
2
3export function displayName(slug) {
4  return String(slug).replace(/-+/g, " ").trim();
5}
6
7// What the learner reads for a step: the plain-words label the plan carries. A plan is
8// only stored with a label for every step (validatePlan).
9export function labelOf(plan, id) {
10  const label = plan?.labels?.[id];
11  return typeof label === "string" ? label : "";
12}
13
14// Edges are [prerequisite, dependent] pairs.
15export function prerequisitesOf(plan, node) {
16  const edges = Array.isArray(plan?.edges) ? plan.edges : [];
17  return edges
18    .filter((edge) => Array.isArray(edge) && edge[1] === node && typeof edge[0] === "string")
19    .map((edge) => edge[0]);
20}
21
hooks/mod/map-layout.mjs 163 lines
1// Imported by the Claude Code mod, which has no Node: import only other plain
2// plugin files, never a `node:` module.
3import { labelOf, prerequisitesOf } from "../vault/graph.mjs";
4
5// The ledger is a file the learner may have edited: a plan counts only when its nodes
6// are an array, and a goal only when it is text.
7const wellFormed = (candidate) => (Array.isArray(candidate?.nodes) ? candidate : null);
8const plan = (ledger) => wellFormed(ledger?.session?.dependencyMap);
9
10// A plan the learner has been shown but has not approved yet (set before approval,
11// removed by it).
12export function proposedPlan(ledger) {
13  return wellFormed(ledger?.session?.proposedPlan);
14}
15
16export function goalOf(ledger) {
17  const goal = ledger?.session?.goal;
18  return typeof goal === "string" && goal !== "" ? goal : null;
19}
20
21export const drawnPlan = (ledger) => plan(ledger) ?? proposedPlan(ledger);
22const statusOf = (ledger, id) => ledger?.nodes?.[id]?.status ?? null;
23
24// Row = longest prerequisite chain above the node. A cycle never crashes: the
25// nodes that cannot be placed go together in the last row.
26export function layers(nodes, edges) {
27  const ids = [...new Set(Array.isArray(nodes) ? nodes : [])];
28  const known = new Set(ids);
29  const preds = new Map(ids.map((id) => [id, []]));
30  for (const edge of Array.isArray(edges) ? edges : []) {
31    if (!Array.isArray(edge)) continue;
32    const [from, to] = edge;
33    if (from !== to && known.has(from) && known.has(to) && !preds.get(to).includes(from)) preds.get(to).push(from);
34  }
35  const rows = [];
36  const placed = new Set();
37  let remaining = ids;
38  while (remaining.length > 0) {
39    let ready = remaining.filter((id) => preds.get(id).every((p) => placed.has(p)));
40    if (ready.length === 0) ready = remaining;
41    rows.push(ready);
42    for (const id of ready) placed.add(id);
43    remaining = remaining.filter((id) => !placed.has(id));
44  }
45  return rows;
46}
47
48export function progress(ledger) {
49  const nodes = plan(ledger)?.nodes ?? [];
50  return { passed: nodes.filter((id) => statusOf(ledger, id) === "passed").length, total: nodes.length };
51}
52
53// The ledger records only passed and failed; the node being taught has neither yet.
54function stateOf(ledger, id) {
55  const status = statusOf(ledger, id);
56  if (status === "passed" || status === "failed") return status;
57  return ledger?.session?.nextNodeId === id ? "in progress" : "not started";
58}
59
60const GLYPHS = { passed: "✓", failed: "✗", "in progress": "●", "not started": "○" };
61
62export function glyph(ledger, id) {
63  return GLYPHS[stateOf(ledger, id)];
64}
65
66export function chipLabel(ledger, id) {
67  return `${glyph(ledger, id)} ${labelOf(drawnPlan(ledger), id)}`;
68}
69
70// The step passed most recently. A batched check passes several at one instant, so a
71// tie goes to the later step in the plan.
72function lastPassed(ledger) {
73  let best = null;
74  let bestAt = "";
75  for (const id of plan(ledger)?.nodes ?? []) {
76    if (statusOf(ledger, id) !== "passed") continue;
77    const at = ledger.nodes[id].lastTaughtAt ?? "";
78    if (best === null || at >= bestAt) [best, bestAt] = [id, at];
79  }
80  return best === null ? null : labelOf(plan(ledger), best);
81}
82
83const line = (glyph, head, tone, { detail = null, last = null, animated = false } = {}) => ({ glyph, head, tone, detail, last, animated });
84
85// What the band above the prompt says, from the ledger alone, so it never shows a stale result.
86// `head` is the state, drawn in `tone` after its glyph; `detail` (the goal) and `last` are drawn
87// plain. Glyph and colour follow the plan: ◌ planning (animated, plain), ◇ proposed (yellow, waiting
88// for the learner), ● in progress (cyan), ✗ retry (red), ✓ done (green). An approved plan is in
89// progress at once: teaching starts when it is approved, long before the first check is recorded.
90export function bandLine(ledger) {
91  const session = ledger?.session;
92  if (!session) return null;
93  const { passed, total } = progress(ledger);
94  const proposed = proposedPlan(ledger);
95  if (session.status === "intake" && proposed) return line("◇", `Plan proposed · ${proposed.nodes.length} steps`, "yellow", { detail: goalOf(ledger) });
96  if (session.status === "intake") return goalOf(ledger) ? line("◌", "Planning", undefined, { detail: goalOf(ledger), animated: true }) : null;
97  if (session.status === "complete") return line("✓", `All ${total} steps passed`, "green");
98  if ((session.status === "planned" || session.status === "in-progress") && session.nextNodeId) {
99    const retry = statusOf(ledger, session.nextNodeId) === "failed";
100    const head = `Step ${passed + 1} of ${total} · ${retry ? "Retry" : "In progress"}: ${labelOf(plan(ledger), session.nextNodeId)}`;
101    return line(retry ? "✗" : "●", head, retry ? "red" : "cyan", { last: lastPassed(ledger) });
102  }
103  return null;
104}
105
106export function snapshot(topic, ledger) {
107  if (!ledger) return null;
108  const statuses = {};
109  const taught = {};
110  const rounds = {};
111  const names = {};
112  for (const [id, node] of Object.entries(ledger.nodes ?? {})) {
113    statuses[id] = node?.status ?? "";
114    taught[id] = node?.lastTaughtAt ?? "";
115    rounds[id] = roundOf(node);
116    names[id] = labelOf(drawnPlan(ledger), id);
117  }
118  return { topic, statuses, taught, rounds, names, next: ledger.session?.nextNodeId ?? null, sessionStatus: ledger.session?.status ?? null };
119}
120
121// The questions a step's last call held; a step recorded before the counts existed is one.
122function roundOf(node) {
123  const { right, wrong } = node?.lastRound ?? {};
124  if (Number.isInteger(right) && Number.isInteger(wrong) && right + wrong > 0) return { right, wrong };
125  return node?.status === "passed" ? { right: 1, wrong: 0 } : { right: 0, wrong: 1 };
126}
127
128// One line for every result recorded since the last poll (a new status, or the same status
129// stamped again by a later check), or null. Counts questions, not steps. The toast display
130// shows one toast at a time, so a batched check (a pass and a miss together) is one line.
131export function changedStatuses(previous, current) {
132  if (!previous || !current || previous.topic !== current.topic) return null;
133  let right = 0;
134  let wrong = 0;
135  let step = null;
136  for (const [id, status] of Object.entries(current.statuses)) {
137    if (previous.statuses[id] === status && previous.taught[id] === current.taught[id]) continue;
138    if (status !== "passed" && status !== "failed") continue;
139    right += current.rounds[id].right;
140    wrong += current.rounds[id].wrong;
141    step = current.names[id];
142  }
143  if (right + wrong === 0) return null;
144  if (right + wrong === 1) return right === 1 ? `✓ ${step}` : `✗ ${step} — retry`;
145  const parts = [];
146  if (right > 0) parts.push(`✓ ${right} correct`);
147  if (wrong > 0) parts.push(`✗ ${wrong} to retry`);
148  return parts.join(" · ");
149}
150
151export function nodeDetail(ledger, id) {
152  const shown = drawnPlan(ledger);
153  const edges = Array.isArray(shown?.edges) ? shown.edges : [];
154  const taught = ledger?.nodes?.[id]?.lastTaughtAt;
155  return {
156    name: labelOf(shown, id),
157    status: plan(ledger) ? stateOf(ledger, id) : "proposed",
158    prerequisites: prerequisitesOf(shown, id).map((p) => labelOf(shown, p)),
159    dependents: edges.filter((e) => Array.isArray(e) && e[0] === id).map((e) => labelOf(shown, e[1])),
160    taught: typeof taught === "string" ? taught.slice(0, 10) : null,
161  };
162}
163
types/index.d.ts 12 lines
1export type MapView = {
2  topic: string | null
3  ledger: unknown | null
4  selected: string | null
5}
6
7declare module 'claude-code' {
8  interface PluginState {
9    sensei: { view: MapView; spin: number }
10  }
11}
12