Skip to content

End-Session Wiki Hook — User Guide

Auto-captures every Claude Code session as a structured note in the Luna Obsidian vault. Built by epic #7 (tasks #24-#27). For the on-disk note shape, see end-session-wiki-schema.md.

Quickstart — multi-vault in 30 seconds

  • Default (zero config): sessions capture into ~/Documents/luna if that’s a real Obsidian vault (the luna template creates it). With no configured vault and no real ~/Documents/luna, the hook skips rather than creating a phantom vault.
  • One command: run /end-session-wiki-setup from the code repo whose sessions you want captured; it walks the options and writes the config for you.
  • The four targeting options (first match wins):
    1. vault_path: an absolute path, this repo only (machine-specific; don’t commit on a shared repo).
    2. vault: a vault name, distributable and safe to commit; resolves per-machine via ~/.claude/luna-vaults.json, else the ~/Documents/<name> convention. Recommended for shared repos.
    3. LUNA_VAULT_PATH: an env var, your global default for every repo.
    4. default → ~/Documents/luna (only if it’s a real vault; otherwise the hook skips, so no phantom vault).

The registry (~/.claude/luna-vaults.json) is optional: you only need it for a vault that doesn’t live at ~/Documents/<name>. Full detail + examples: Choosing the target vault.

Your first capture in a vault is an orphan (no inbound links) until you build the index, so run scripts/sessions-reindex.sh once afterward (see Connecting notes into the graph).

What it does

On every SessionEnd event, a hook runs scripts/hooks/end-session-wiki.sh. It reads the session transcript + git metadata, renders a session note matching the schema, and writes it into the Luna vault at sessions/YYYY/MM/YYYY-MM-DD-HHMM-<repo>-<branch>.md. The hook is silent on success and never blocks session end.

Enabled by default. It runs automatically on every session end. No setup is needed to start capturing, and a stock install writes into ~/Documents/luna. Opt out per-session or per-repo (see How to opt out); sessions shorter than min_duration_seconds (default 60s) are skipped so accidental opens aren’t captured.

What the note contains. Frontmatter (repo, branch, worktree, timestamps, duration_minutes, files_touched, tags) plus six fixed sections: Summary, Decisions, Files Touched, Commands, Follow-ups, Raw Conversation (full shape: end-session-wiki-schema.md). Two things to expect, so a sparse note doesn’t read as a bug:

  • Empty sections are written as _None._ instead of being dropped, so the six-section shape is always present.
  • ## Decisions / ## Follow-ups are scaffolding, only as complete as the transcript made parseable, so fill them in yourself when a session mattered; and files_touched counts the working-tree diff over the session window, so a session whose work was already committed (clean tree at session end) shows 0. Neither is a failure.

The note is delivered via the Obsidian Local REST API when an API key is available. If no key is found, or the REST PUT fails (e.g. Obsidian isn’t running), the hook falls back to writing the note directly to the vault on disk. Obsidian picks up on-disk changes automatically, so capture works whether or not the plugin is up.

Flow — three separate pipelines (don’t conflate them)

Session capture is one of three independent lanes that touch the vault. They’re often confused; only the first writes sessions/:

flowchart TD
    subgraph L1["Lane 1 — Session capture (this hook + /luna-backfill)"]
        A1["SessionEnd hook (live)"] --> H{"husk-skip gate<br/>(no salvageable content?)"}
        A2["/luna-backfill (historical)"] --> H
        H -- "content-free" --> SKIP["skip (no note)"]
        H -- "has content" --> M["mechanical note<br/>crystallized: false"]
        M --> C{"crystallize?"}
        C -- "live: auto-spawn now<br/>backfill: later, via --recrystallize" --> X["LLM crystallizer<br/>→ crystallized: true"]
        C -- "no claude / capped / disabled<br/>(plain backfill stops here)" --> KEEP["mechanical note stands"]
        X --> S["&lt;vault&gt;/sessions/YYYY/MM/"]
        KEEP --> S
        S -. "sessions-reindex.sh (on demand)" .-> G["session graph<br/>(_index.md + repo hubs)"]
    end
    subgraph L2["Lane 2 — Clip pipeline (pipeline-cadence)"]
        CL["Clippings/ only"] --> CLP["harvest → triage → synthesize → archive"]
    end
    L1 ~~~ L2
    L2 ~~~ L3
    subgraph L3["Lane 3 — Memory-compound"]
        MEM["agent project_* memories"] --> SUB["luna substrate"]
    end
  • Lane 1 (session capture) routes per session to its configured vault (multi-vault under --all; no configured/real vault → skip). There is no nightly pass over sessions/.
  • Lane 2 (clip pipeline) is the pipeline-cadence daily + weekly and operates on Clippings/ only; it does not touch sessions/.
  • Lane 3 (memory-compound) compounds the agent’s project_* memories into the luna substrate, which is separate again from session notes.

Known gap (future work): there is no cross-session synthesis/compounding of session notes yet, the sessions/ analogue of synthesize-clips. That’s a distinct future ticket, separate from memory-compound (which mines agent memories).

Crystallization (LLM upgrade)

The hook first writes a mechanical note: frontmatter + the six sections rendered straight from the transcript (last assistant turn → Summary, parsed commands → Commands, the git diff → Files Touched). That note carries crystallized: false. Immediately afterward, on every success-write path, the hook spawns a best-effort background crystallizer that asks a bounded claude run to rewrite the four prose sections (Summary, Decisions, Files Touched, Follow-ups) into a real synthesis of the session, and optionally append a seventh, ## Lessons (JSONL-form lesson records; see end-session-wiki-schema.md §3.6a and docs/internals/lesson-provenance.md), when it judged the session surfaced a genuine reusable lesson. When the body actually changes, the script (not the model) flips the note to crystallized: true (+ crystallized_at). All other frontmatter (date, session_id, repo, branch, worktree, source) and the Raw Conversation callout are preserved verbatim.

A note that stays crystallized: false is the valid mechanical baseline, not a failure: the crystallizer is fail-open and recoverable (see Reheal).

Mechanism: one API-lane call, billed to the API. The crystallizer is scripts/luna/crystallize-note.sh. Its only launch path is scripts/api-lane/claude-api.sh (one print-mode -p call, --permission-mode acceptEdits, a per-call --max-budget-usd of $CRYSTALLIZE_MAX_BUDGET_USD, default 1.00), with HIMMEL_API_TOOLS=Read,Edit and HIMMEL_API_KIND=bulk. It never touches the Max subscription. The lane accepts no extra-directory or settings flags, so the run uses cwd = the note’s directory: the note is in the workspace, and the Read tool still reads the transcript outside it (verified by a real call). The model is CRYSTALLIZE_MODEL, else the script’s DEFAULT_MODEL (the same model this repo’s crystallize_model config value names). A cheaper-model A/B (haiku-5-5, sonnet-5-5) did not pass the lesson-recall bar, so the model stayed and the billing moved; an observed call costs about $0.17.

Fails closed. The crystallizer needs HIMMEL_API_LANE=on and HIMMEL_API_ACCOUNT=A|B|auto in the launching shell. Without them (or with any other account value) it prints crystallize-note: refusing claude-api: the API lane is off ... no claude-cli fallback and exits 2, leaving the mechanical note untouched at crystallized: false; CRYSTALLIZE_CLAUDE_BIN (the test seam) does not bypass this gate. A hook-spawned crystallizer inherits the launching shell’s env, so a Claude session started without those two variables writes only the mechanical note and backfill-sessions.sh --recrystallize crystallizes it later from a shell that has them (--reheal is husk-only, and with the lane off it falls back to a mechanical re-render). A lane refusal after the gate (no credit, over the per-call budget) exits 0, also leaving the mechanical note. The spawn is fully detached (scripts/lib/detach.sh: setsid where available, else a backgrounded job released with disown so the hook’s exit never waits for it) so it outlives the hook’s process-group teardown; the hook never blocks on it and never exits non-zero because of it.

Edit-confirmed flag. The crystallized flag is owned by the script, not the LLM: it compares the note’s hash before/after the lane call and sets crystallized: true only when the note actually changed (the prompt forbids touching the frontmatter, so any change is a body change). A no-op run (cap hit, refusal, budget exhausted, or a model that judged nothing needed changing) leaves the note byte-unchanged at crystallized: false; a real synthesis always flips it, so the flag can never be falsely set, and a hermetic test asserts the lane call’s shape (cwd, tools, kind, budget, and no extra-directory or settings flags).

Recursion guards. The throwaway crystallizer session must not re-fire the session-end hooks. crystallize-note.sh exports two env vars into the spawned claude:

Env varEffect
CLAUDE_END_SESSION_WIKI=0Silences this hook in the crystallizer subsession (no nested capture).
HIMMEL_WHERE_ARE_WE=0Silences the where-are-we session-end refresh so a crystallizer subsession doesn’t kick off a jira/gh/git ledger refresh.

Safety rails (best-effort). The lane gate above and a missing or unreadable launcher fail closed (exit 2); any other unavailability leaves the mechanical note untouched and exits 0:

  • API lane off or no account → refusal, exit 2, no fallback (see Mechanism).
  • Concurrency cap (CRYSTALLIZE_MAX_CONCURRENCY, default 2) never piles up N simultaneous lane calls for N simultaneous session-ends; over the cap → skip.
  • Retry-read: the live hook may have just written the note via the Obsidian REST API (async flush); the crystallizer retries the read a few times before giving up.
  • Already crystallized → no-op.

Husk-skip (the empty-note gate). A session with no salvageable content (no assistant text, no thinking/tool_use, no extracted commands) would render a content-free “Transcript unavailable” husk. The hook (and backfill) detect this (HAS_CONTENT==0, plus files_touched==0 for the live hook) and skip the write entirely, logging skipped: husk (no content). A thinking/tool-only session (real work, no final prose turn) is not a husk: its Summary surfaces the command activity instead.

Disable crystallization (keep mechanical capture): set "crystallize": false in .claude/end-session-wiki.json. The mechanical note is then the final note. Pin a model with "crystallize_model".

Reheal — recover existing husk notes

If husk notes already exist in a vault (e.g. captured before the husk-skip gate landed, or sessions whose crystallizer didn’t run), backfill-sessions.sh --reheal recovers them. It scans the resolved vault’s sessions/**/*.md for husk notes: frontmatter crystallized is not true AND the Raw Conversation contains _Transcript unavailable._ AND Files Touched is _None._. It locates the matching transcript by session_id under ~/.claude/projects/*/, and:

  • if the transcript now has salvageable content → overwrites the note in place via the crystallizer (or, when claude is unavailable, a mechanical re-render, but only when the transcript has a final assistant prose turn, which is what clears the “Transcript unavailable” marker; a tool/thinking-only session can only be lifted by the LLM crystallizer);
  • if the transcript is genuinely contentless, missing, or only mechanically un-liftable this run → leaves the husk as-is (a later run with claude available crystallizes it).

Idempotent: a healed (crystallized: true) note, and any note a mechanical pass can’t lift, is left byte-unchanged on re-run (no rewrite loop). Inert husks persist; there is no auto-delete. See /luna-backfill --reheal.

Recrystallize — crystallize content-bearing notes --reheal skips

--reheal is husk-only (the three-part predicate above), so it skips a note that has a real Raw Conversation but is still crystallized: false, which is exactly the shape of a backfilled prose-session note (plain backfill-sessions.sh writes mechanical crystallized: false notes) and of an F1-affected live note. backfill-sessions.sh --recrystallize closes that gap: it crystallizes any note with crystallized != true that has a recoverable (content-bearing) transcript, not just husks. It is LLM-only (a mechanical re-render stays crystallized: false), so a missing claude skips rather than degrades.

  • --limit N caps real crystallizations per run (token-cost guard; one claude run per note). Remaining notes recover on a later run (idempotent). Run --recrystallize --dry-run first: it reports the FULL recoverable count (ignores --limit) so you can size batches against the real cost.
  • A long run is observably-progressing: a periodic scanned X/total heartbeat plus a per-note line as each crystallization lands print to stderr; the machine-parseable summary line stays on stdout. See /luna-backfill.

So: --reheal for husks (contentless-looking notes captured before the husk-skip gate), --recrystallize for content-bearing-but-uncrystallized notes (backfilled prose sessions, the common case).

Security note — log files contain raw transcript text

The hook writes diagnostic output to ~/.claude/logs/end-session-wiki/<project-slug>.log and (during dry-run mode) the full rendered note including the ## Raw Conversation callout that quotes the tail of your session transcript. Anything you typed into Claude (pasted credentials, API keys, secrets) can appear verbatim in those logs.

The log lives outside any repo (per-machine, keyed by project; see Logs below), so it can never end up committed. If you copy it off-disk for debugging, redact secrets first. The hook sets umask 077 before creating anything, so the log directory/files are created owner-only (0700/0600) rather than inheriting a permissive shell umask.

How to opt out

There are two equivalent controls; pick whichever matches your scope.

Env var (per-shell or per-session):

Terminal window
# Disable for a one-off run:
CLAUDE_END_SESSION_WIKI=0 claude

Accepted off-values (case-insensitive): 0, false. Anything else (including unset) leaves the hook enabled.

Repo config (persistent, per-repo):

Edit .claude/end-session-wiki.json:

{
"enabled": false,
"dry_run": false,
"min_duration_seconds": 60,
"crystallize": true
}

Fields:

FieldTypeDefaultEffect
enabledbooltruefalse → hook exits 0 after logging skipped: config disabled
dry_runboolfalsetrue → render note to log file instead of writing to vault
min_duration_secondsint60Sessions shorter than this are skipped (prevents capturing accidental opens)
crystallizebooltruefalse → never spawn the LLM crystallizer; the mechanical note is the final note. See Crystallization.
crystallize_modelstring(absent)Default model for the crystallizer (a model id string). Absent → the crystallizer’s default model. A CRYSTALLIZE_MODEL env var set in the launching shell overrides the config value (per-session operator switch, e.g. CRYSTALLIZE_MODEL=claude-sonnet-5-5 claude).
crystallize_rulesstring(absent)Path (a leading ~/ is expanded) to an operator rules/context file whose content is appended to the crystallizer prompt (“Additionally apply these operator rules…”). The file is trusted operator input: its content is interpolated verbatim into the crystallizer prompt, so only point it at a file you control. The content is wrapped between the literal delimiter lines --- begin operator rules --- and --- end operator rules ---, so a rules file whose own body contains those exact lines could confuse the prompt framing. Unreadable/missing → the crystallizer proceeds with no rules block (fail-open; the hook logs crystallize_rules not readable). Exported to the crystallizer as CRYSTALLIZE_RULES_FILE.
vault_pathstring""Absolute path (a leading ~/ is expanded) to the Obsidian vault this repo’s sessions are captured into. Empty → fall back to vault, then LUNA_VAULT_PATH env, then the default. See Choosing the target vault below.
vaultstring(absent)Vault name (not a path), e.g. "salus". Distributable/safe to commit; resolved to a path per-machine (registry, then the ~/Documents/<name> convention). An invalid or unresolvable name skips the capture rather than misrouting it. See Choosing the target vault.

Missing file → defaults applied (enabled: true, dry_run: false, min_duration_seconds: 60); vault_path/vault absent.

The file must be one valid JSON object. A syntax error, a comment, an empty file or a non-object value (null, an array, a string) makes the hook fail closed: it skips capture for this repo. A leading UTF-8 byte-order mark is tolerated. With a jq that does not skip a BOM, every field except vault_path / vault falls back to its default, so "enabled": false and "dry_run": true would be ignored; save the file without one to be safe.

Choosing the target vault

Your vault almost certainly does not live where the operator’s does, and you may keep more than one (e.g. a general vault plus a project-specific one). The hook resolves the target vault in this order (first match wins):

  1. vault_path in .claude/end-session-wiki.json (per-repo, absolute path): the most specific, highest priority. An absolute path is machine-specific, so prefer vault (below) for anything you commit and share.
  2. vault name in .claude/end-session-wiki.json (per-repo, distributable): a vault name instead of a path, so the same committed config works on every machine. Resolved per-machine: first the operator registry ~/.claude/luna-vaults.json, else the convention ~/Documents/<name>. The convention target must be a real vault (contain an .obsidian/ folder); a name that resolves to no real vault, or fails validation (1–64 chars, must match [A-Za-z0-9._-], start alphanumeric, no / or ..), skips the capture rather than misrouting it (logged as skipped: vault …). A config file that exists but is not valid JSON also skips (fail-closed) rather than falling through to the default, so a malformed config can’t silently leak a sensitive repo’s sessions into the general vault.
  3. LUNA_VAULT_PATH environment variable (global): your default vault for everything that doesn’t override it per-repo. The hook reads the live process environment, so set it in ~/.claude/settings.json’s env block (what /end-session-wiki-setup and scripts/adopt.sh write, via scripts/lib/wire-luna-vault.sh) or export it from your shell profile. Putting it in the repo-root .env does nothing for this hook; that key is not bridged (see .env.example). A settings.json env value is applied at session start, so it takes effect for new sessions.
  4. Built-in default: the luna vault: the luna entry in ~/.claude/luna-vaults.json if you have one, else the ~/Documents/luna convention. The bare convention is honored only when it is a real vault (contains an .obsidian/ folder), so a stock luna install works with zero config. With no configured vault and no real ~/Documents/luna, the hook skips (logged skipped: vault unresolved …) rather than materializing a phantom vault for someone who never set luna up. An explicit luna registry entry is honored as-is (no existence check), like vault_path.

vault_path configures a path (renaming/moving the vault means updating that path). vault configures a name and each machine resolves the path, so the same committed value works everywhere: an operator either follows the ~/Documents/<name> convention (zero extra config) or maps the name in their registry.

Set the target interactively with the /end-session-wiki-setup command: run it from your code repo and it writes the value for you (any of the options above). The luna template setup (templates/luna-second-brain/scripts/setup.sh) prints the same options after a fresh install but doesn’t write them. Or configure by hand. Each file must be one valid JSON object (JSON has no comments); a .claude/end-session-wiki.json that is not valid JSON, or not an object, skips capture.

.claude/end-session-wiki.json: capture this repo’s sessions into a specific vault (absolute path):

{ "vault_path": "~/Documents/my-vault" }

.claude/end-session-wiki.json: route by vault name (distributable, safe to commit):

{ "vault": "salus" }

~/.claude/luna-vaults.json: per-machine name→path map, needed only for vaults that don’t live at ~/Documents/<name>:

{ "vaults": { "salus": "~/Documents/salus" } }

~/.claude/settings.json: global default vault for all repos (process env, applied at session start):

{ "env": { "LUNA_VAULT_PATH": "/home/me/Documents/my-vault" } }

Set the variable with the helper or a direct export:

Terminal window
# write it into ~/.claude/settings.json with the helper (recommended)
bash scripts/lib/wire-luna-vault.sh ~/.claude/settings.json ~/Documents/my-vault
# …or export it from your shell profile before launching claude (NOT the repo .env)
export LUNA_VAULT_PATH=~/Documents/my-vault

Logs

Path: ~/.claude/logs/end-session-wiki/<project-slug>.log, per-machine, outside any repo. The hook is registered globally, so it fires in every repo a session runs in; a repo-local log previously caused merge-conflict junk (and, in dry_run mode, leaked raw transcript text) into shared/synced repos. <project-slug> is the same cwd-encoding Claude Code uses for its own ~/.claude/projects/<slug> dirs (path separators and : replaced by -), derived from $CLAUDE_PROJECT_DIR (falls back to pwd), so each repo still gets its own log file, just outside the repo tree. The directory is created automatically on first write.

Every hook invocation appends one line: [<UTC-ISO timestamp>] <message>. Messages include wrote <path> (<ms>ms), skipped: <reason>, and ERROR: <detail> (for failed vault writes). In dry-run mode, the full rendered markdown is appended between === separators so you can inspect the exact note that would have been written.

Rotation: when the log exceeds 1 MB, it is renamed to <project-slug>.log.old in the same directory (overwriting any prior .log.old) and a fresh log begins on the next write. Only the two most recent files (.log + .log.old) are retained.

The degradation marker (<project-slug>.degraded) lives alongside the log in the same per-machine directory.

Inspecting captured notes

Notes live under <luna-vault>/sessions/YYYY/MM/. Default vault root: $HOME/Documents/luna, overridable per-repo with vault_path or vault in .claude/end-session-wiki.json, or globally with LUNA_VAULT_PATH (see Choosing the target vault).

Search across captured sessions via the Obsidian MCP (on demand: launch with the vault mcp-profile, see .claude/mcp-profiles/README.md; the capture hook itself uses the REST API, not this MCP):

mcp__obsidian-vault__obsidian_simple_search query:"end-session-wiki"

Or filter by branch in the file path: sessions/2026/05/2026-05-18-*-feat-end-session-wiki-*.md.

Connecting notes into the graph

The hook files each note but does not maintain an index, so a fresh capture is an orphan (no inbound links) and its [[<repo>]] preamble link dangles until a hub note exists. Run scripts/sessions-reindex.sh to fix both, idempotently:

Terminal window
bash scripts/sessions-reindex.sh # default ~/Documents/luna
bash scripts/sessions-reindex.sh --vault ~/Documents/salus

It regenerates <vault>/sessions/_index.md (links every session note → no orphans) and creates a sessions/<repo>.md hub for each repo that doesn’t already resolve [[<repo>]] somewhere in the vault. Lean-invoke: run on demand after a batch of captures (or wire it into the clip-pipeline cadence).

Disabling per-session

One-shot disable for a single Claude run, no config edit needed:

Terminal window
CLAUDE_END_SESSION_WIKI=0 claude

The env var takes precedence over the config file. It is useful for throwaway exploration sessions or when you want to verify the hook’s no-op path without editing repo state.