gandalf-lessons
v1.0.0
Published
Teaches the code changes behind each commit/ticket — multi-level, scroll-narrated lessons generated by Claude Code.
Readme
gandalf
Teaches the code changes behind each commit: multi-level, scroll-narrated lessons generated by Claude Code.
A raw git diff shows what changed line by line, but not what changed behaviorally, or why. gandalf takes a diff (any two refs, or HEAD vs your working tree) and drives Claude Code itself, headless, to analyze it across four lenses: behavioral, module-dependency, contract, and data flow. On top of those it adds complexity, engineering patterns, and active-recall material, then renders everything as an interactive, scroll-narrated web app. After a lesson you understand the change at every altitude, not just the diff.
Every metric is computed deterministically in Node and fed to Claude as ground truth. Claude interprets the numbers, it never invents them, and every judgment is anchored to quoted lines.
See a sample lesson →, generated from a Claude-co-authored commit in Simon Willison's datasette (
591b909a, "Escape table names with [square] brackets"). No install needed.
The techniques behind each format, and the program-comprehension, multimedia-learning, and retention research backing them, are documented in docs/RESEARCH.md.
1. Installation
Prerequisites
| Requirement | Why |
|---|---|
| Node.js ≥ 20 | runtime (ESM + tsx) |
| git | gandalf reads diffs and history from a git repo |
| Claude Code CLI, installed and logged in | gandalf drives claude -p in headless mode and reuses your existing Claude Code login. It does not use an ANTHROPIC_API_KEY |
Optional tools sharpen the deterministic metrics if present on your PATH (gandalf degrades gracefully without them): scc (size/COCOMO), lizard (per-function cyclomatic complexity, incl. Swift), swiftlint (Swift), difftastic.
Install
Nothing to build — the package ships with the viewer prebuilt:
npx gandalf-lessons generate # teach the current repo's HEAD vs working tree
npx gandalf-lessons serve # browse the lessons at localhost:4173Or install it once so the command is just gandalf:
npm install -g gandalf-lessons
gandalf generateConfirm Claude Code is ready:
claude --version # should print a version; if not, install + log in to Claude Code firstThroughout this README, gandalf <cmd> and npx gandalf-lessons <cmd> are interchangeable.
Install from source (contributors)
git clone https://github.com/midhunkrishna/gandalf.git
cd gandalf
npm install
npm run build:web # build the viewer; required for `gandalf serve` and `gandalf build`Optional: scripts/install.sh drops a gandalf launcher for the checkout on your PATH:
./scripts/install.sh # installs `gandalf` to ~/.local/bin
GANDALF_BIN_DIR=/usr/local/bin ./scripts/install.sh # …or elsewhereThe launcher resolves Node itself (handy with mise/nvm), so gandalf works from any directory, including the non-interactive shells used by automation.
2. Running gandalf
Generate a lesson
# default: HEAD vs the uncommitted working tree, analyzing the current repo
npm run gandalf -- generate
# any two refs
npm run gandalf -- generate --from <baseRef> --to <targetRef>
# scope to a ticket (overlays the ticket's intent as the "why")
npm run gandalf -- generate --ticket P1-003Generation drives several claude -p calls (per-file passes plus a fan-out of focused synthesis passes), so it takes a few minutes and consumes Claude usage. The resulting lesson is saved to:
<repo>/.gandalf/lessons/<id>/lesson.jsongenerate options
| Flag | Default | Meaning |
|---|---|---|
| --from <ref> | HEAD | base ref |
| --to <ref> | (working tree) | target ref; omit to diff uncommitted changes |
| --ticket <id> | (none) | force a ticket id for the intent overlay |
| --cwd <dir> | current dir | repository to analyze |
| --out-dir <dir> | (configured location) | where to write the lesson; overrides lesson_location (see Configuration) |
| --lite / --full | --full | generation profile (see below) |
| --model-file <m> | (profile: sonnet full, haiku lite) | model for per-file passes; overrides the profile |
| --model-synth <m> | (profile: opus full, sonnet lite) | model for the synthesis pass; overrides the profile |
| --concurrency <n> | 4 | parallel per-file passes |
Generation profiles
A full lesson is expensive. Five changed files cost about $3.28, and three quarters of that is the seven opus synthesis passes. That is fine for a commit worth studying and far too much for every commit that lands during a work day.
The lite profile buys volume instead of depth. Per-file passes run on haiku, escalating to sonnet for a file with more than 150 changed lines or one of the repo's top three hotspots, and the seven synthesis passes collapse into one sonnet pass. On the same five-file sample commit that measured about $0.50, roughly a sixth of the full cost.
| | Full | Lite | |---|---|---| | Keeps | every lens | Overview, Dependencies, Walkthrough (with beacons), Behavioral, Contracts, Complexity | | Drops | nothing | Patterns, Data flow, Recall, and the tiered "Explain for" text | | File passes | sonnet | haiku, sonnet on heavy files | | Synthesis | 7 passes on opus | 1 merged pass on sonnet |
Everything deterministic is unaffected: contracts, beacons, TL;DRs, complexity metrics and the dependency graph's node set come from the file passes and the evidence bundle, not from the synthesis pass.
Resolution is flag > config > command default, and gandalf generate defaults
to full while gandalf watch defaults to lite (watch teaches every commit, so
volume decides). Watch prints the profile it resolved at startup.
gandalf generate --lite # cheap lesson for this diff
gandalf watch --full # spend the money on every commitSet generation_profile: "lite" (or "full") in ~/.gandalf/config.yaml to pin
both commands to one profile. A lite lesson is marked lite in the viewer's
header and lesson list, and the lenses it never generated are hidden rather than
shown empty.
The honest framing: lite is for watch-mode volume, so a day of twenty commits
costs about ten dollars instead of sixty-five. When a commit is worth studying,
re-run it with gandalf generate --full.
Browse lessons in the viewer
npm run gandalf -- serve # http://localhost:4173
npm run gandalf -- serve --port 4300Open the printed URL. The viewer lists every persisted lesson (newest first) in the Lessons menu.
List persisted lessons
npm run gandalf -- listCheck lesson integrity
npm run gandalf -- doctor # all lessons
npm run gandalf -- doctor --lesson <id>
npm run gandalf -- doctor --fix # apply the graph repairs it reportsReports cross-section drift (graph nodes that resolve to no file, contract/evidence references to unknown files, beacons past EOF, malformed diagrams). generate runs the same check automatically and prints warnings. Exit code 1 when errors are found.
Graph repairs are printed for every run and written only with --fix: an edge pointing at a missing node gains that node (status unchanged) when the id reads like a module, and is dropped when it does not. --fix rewrites the stored lesson.json in place and never deletes a lesson.
Export a self-contained lesson
npm run gandalf -- build --out lesson.html # newest lesson
npm run gandalf -- build --lesson <id> --out lesson.htmlSee §4.
3. Configuration
gandalf reads an optional user-level config from ~/.gandalf/config.yaml
(created by scripts/install.sh; never overwritten on re-install). The file is
self-documenting: every key ships with an inline comment, and future keys ship
commented-out (disabled). Delete any key (or the whole file) to fall back to the
documented default. GANDALF_HOME_DIR relocates ~/.gandalf (used by tests).
| key | default | meaning |
|---|---|---|
| generation_profile | (unset) | Which profile to use when neither --lite nor --full is given (see Generation profiles). Ships commented out: while it is unset, generate runs full and watch runs lite. Set it to "lite" or "full" to pin both commands. |
| lesson_location | "home-dir" | Where lesson libraries live. "home-dir": ~/.gandalf/<project-name>-<root-commit-sha12>/lessons. Lessons never touch the analyzed repo (no lesson commits, no working-tree writes), and the store key uses the repo's root (first) commit, so it survives moving or renaming the project directory. "project-wd": <repo>/.gandalf/lessons, the pre-config in-repo behavior. |
Precedence everywhere: --out-dir flag > lesson_location > default.
gandalf doctor prints the resolved config and store location for a repo.
4. Watch mode
Teach commits as they land, orthogonally to whatever produces them. Open a side shell and leave it running:
gandalf watch --cwd <path-to-project> # foreground; Ctrl-C to stopThe watcher polls HEAD (default every 5s), waits for a quiet period
(debounce, default 5s; rebases and merges in progress are waited out), then
teaches each new first-parent commit serially, in commit order. Progress and
errors stream to stdout (watch defaults to --log-level debug).
State lives in watch-journal.json beside the lesson store, never in the
analyzed repo. One watcher per store (a lockfile enforces it).
gandalf watch --once # process the pending backlog, then exit (exit 1 if any failed)
gandalf watch --once --retry-failed # also re-attempt previously failed commits
gandalf watch --status # journal summary: frontier, counts, last failures
gandalf watch --from HEAD~10 # first run only: backfill baseline (default: HEAD = future commits only)Semantics worth knowing:
- Commit subjects like
E1-033: …are parsed as ticket ids (nicer lesson ids plus the intent overlay);--no-infer-ticketdisables this. - Skipped, not failed: merge commits (unless
--include-merges, in which case the first-parent diff is taught), empty diffs, and commits touching only ignored paths (dist/, lockfiles,.gandalf/, and so on). - A failure is terminal: the frontier advances so one bad commit never
wedges the watcher.
--retry-failedre-enqueues failures explicitly (re-generating overwrites that commit's lesson, which is intentional). - Amend/rebase: rewritten history is not re-taught; the watcher resumes
from the merge-base and records a
rewriteevent. Lessons for pre-rewrite commits stay in the library (no pruning yet). With no common history (or a shallow clone boundary) the baseline resets to HEAD with a warning; gandalf never guesses. - Lite by default: watch teaches every commit, so it uses the lite profile
unless you pass
--fullor setgeneration_profilein the config. The resolved profile is logged at startup. See Generation profiles. - Cost valve: a first plan with >25 pending commits refuses and asks for
--max <n>or--from <ref>, because each lesson costs minutes of claude usage. - Ctrl-C once finishes the in-flight lesson (it is minutes of paid work);
twice aborts (exit 130). Claude subprocesses have no abort plumbing, so
detached/
nohupuse can orphan them; run watch in a foreground shell.
5. Using / reading the lessons
Open a lesson in gandalf serve. Each lesson is a set of lenses (tabs), presented top-down: overview → zoom & filter → details on demand.
| Lens | What it answers | |---|---| | Overview | Verdict (behavioral vs refactor-only), breaking count, changed files, top hotspots | | Dependencies | A module-dependency graph (color-coded by change). Click a module for its before/after TL;DR, contract deltas, and the diff | | Walkthrough | A scroll-narrated tour: stepping prose on the left, a pinned code panel on the right with diff colouring (added = green, modified = yellow, removed = red marker) | | Behavioral | The behavioral verdict, a one-line conditional-equivalence statement, and illustrative Trace Cards (input → before/after) | | Contracts | Changed signatures with a Design-by-Contract Safe / Breaking verdict and pre/postcondition deltas | | Data flow | A Mermaid sequence diagram + a Sankey of quantitative flow + before/after narrative | | Complexity | Δ-scorecard, per-function before→after slope charts, and a hotspot treemap (area = churn, intensity = complexity) | | Patterns | Design/architecture patterns & smells as a before→after delta, plus an ADR with "Considered Options" | | Recall | Retrieval-practice questions to test (and retain) the change |
Controls
- Explain for (ELI5 / Junior / Senior / Architect): re-explains a lens at the chosen altitude. Shown only on lenses that have tiered text.
- Quiz (default on): predict-then-reveal. Guess the after-output / Safe-or-Breaking before it's shown, or toggle off to read passively.
- Review: spaced repetition over recall questions you've rated, across your whole lesson library.
- +N / All files: config/lockfile/dotfile noise is hidden by default; reveal it with this header toggle. Hidden files are also excluded from the complexity & hotspot metrics.
- Maximize / Split (Dependencies): make the diff full-width, or switch to a GitHub-style before|after split.
- Maximize (Mermaid diagrams): open a pan/zoom overlay (scroll to zoom, drag to pan).
- Collapse hero (chevron): hide the title block to give the lenses more vertical room.
- Dark mode and the Lessons timeline live in the header (theme + depth tier persist).
- Addressable views: the URL tracks where you are (
#/lesson/<id>/<tab>, plus the selected module on Dependencies,#/library,#/tokens), so reloads keep your place and links can be pasted and shared. Hash-based, so it also works in the offline single-file export. - Contract ↔ diff linking: click a contract's symbol to open its exact lines in the Dependencies diff (
?l=<line>); a pinned "← Back to " chip returns you to that contract row. Anchors are content-addressed (file::symbol), so stale links degrade to the right file or tab, never a wrong target. - Library (header toggle): a gallery landing page of every lesson as a cover card; click to open.
- Share card (in the lesson hero): downloads a 1200×630 branded PNG of the lesson for posting.
- Esc resets any non-default view state (closes the diagram zoom, un-maximizes the diff).
Honesty note: Trace Cards are reasoned from the code, not executed; they're labelled "illustrative." Every pattern/behavior claim carries quoted-line evidence and a confidence level.
6. Exporting a static asset
gandalf build inlines the viewer plus a lesson into one self-contained HTML file that renders fully offline, with no server and no network (fonts fall back to system fonts offline):
npm run gandalf -- build --lesson <id> --out lesson.htmlAttaching it to a GitHub PR / MR
GitHub (and GitLab) don't render arbitrary HTML inline in a review, and GitHub comment attachments reject raw .html. Practical options:
- Zip & attach:
zip lesson.zip lesson.html, then drag the.zipinto a PR/MR comment. Reviewers download, unzip, and openlesson.htmlin any browser (works offline). - CI artifact: in CI, run
gandalf buildand uploadlesson.htmlwithactions/upload-artifact; reviewers download it from the run summary. - Static host / Pages: publish the file (GitHub Pages, S3, Netlify, …) and link it from the PR description.
Notes:
- The export embeds a single lesson; the spaced-Review feature in an export only sees that lesson.
- The file is large (everything is inlined). Prefer zipping for attachments.
7. Contributing
Layout
bin/gandalf.ts # commander CLI: generate | serve | build | list | doctor
src/core/ # engine: git · evidence · noise · tickets · claude · schemas · prompts · pipeline · lesson
src/server/serve.ts # Hono server: static viewer + lesson API
web/ # the React + Vite viewer (lenses, components, design tokens)
test/ # engine unit tests (vitest)
scripts/ # Playwright visual-sanity harnessesDev loop
npm run dev # Vite dev viewer (uses a bundled sample lesson; no server needed)
npm run typecheck # engine (tsc)
npm run typecheck:web # viewer (tsc)
npm test # engine + lib unit tests (modules / noise / tickets / validate / claude shim / diff parser)
npm run build:web # production build of the viewerThe scripts/*.mjs Playwright harnesses screenshot the running viewer for visual review (they expect a gandalf serve instance and use the system Chrome).
Conventions & constraints
- TypeScript ESM throughout; match the surrounding code's style.
- Drive Claude only via the Claude Code CLI (
claude -p). Don't add the Agent SDK or the raw Messages API; both require an API key, which breaks the "reuse your Claude Code login" contract. - Two-plane analysis: compute hard numbers deterministically in
src/core/evidence.ts; Claude interprets them but never invents metrics. Keep judgments evidence-anchored. - Design system: the viewer uses warm-editorial CSS-variable tokens. Stay on the type/spacing/color/motion scales, keep AA contrast in both themes, honor
prefers-reduced-motion, and keep new surfaces dark-mode safe (theme via CSS vars; re-render Shiki/Mermaid on theme change viauseIsDark). - Before opening a PR:
typecheck+typecheck:web+ the engine tests +build:webshould pass, and a quick visual pass via the sanity scripts is appreciated.
8. License
Released under the MIT License; see LICENSE.
MIT © Midhun Krishna