Skip to content

Hooks

What cortexes runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
cortexes
256 skills6 commands2 hooks

Claude Code

> /plugin marketplace add XBlueSky/cortexes
> /plugin install cortexes@cortex

What fires, and when

SessionEnd

  • bash ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/session-end-record.sh

SessionStart

Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.

  • bash ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/session-start-inject.sh
Read hooks/hooks.json

In the plugin's words

How cortexes describes its own hook set.

Cortexes plugin hooks — session recording and memory injection

Where it lives

  • hooks/deck-core.test.tsGitHub
    Read the script
    import { describe, expect, test } from 'claude-code/testing'
    import {
      cutMenu, deckPath, HANDOFF_THRESHOLD, MENU_END, MENU_START,
      modeAfterSubmit, modeForDeck, parseDeck, reminderStep,
    } from './deck-core.ts'
    
    const DECK = {
      v: 1, source: 'startup', repo: 'cortex',
      status: [{ text: '有 2 個 commit 尚未推送', warn: true }],
      actions: [{ label: '載入記憶', prompt: 'p1' }],
      batons: [{ label: 'line', prompt: 'p5' }],
      handoff: 'h',
    }
    const deckLine = (deck: unknown) =>
      `<!-- cortex:deck ${JSON.stringify(deck).replace(/</g, '\\u003c').replace(/>/g, '\\u003e')} -->`
    const block = (line = deckLine(DECK)) =>
      `${MENU_START}\n${line}\n在你第一次回覆使用者時,呈現以下格式:\n1. ...\n${MENU_END}\n`
    
    describe('cutMenu', () => {
      test('cuts only the fenced block and returns its deck', () => {
        const cut = cutMenu(`before\n[Cortexes] front\n\n${block()}after`)
        expect(cut?.text).toBe('before\n[Cortexes] front\n\nafter')
        expect(cut?.deck.repo).toBe('cortex')
      })
      test('runs to the last closing sentinel', () => {
        const quoted = `${MENU_START}\n${deckLine(DECK)}\n5. 載入交接[x]:quotes ${MENU_END} here\n${MENU_END}\nafter`
        expect(cutMenu(quoted)?.text).toBe('after')
      })
      test('no opening sentinel', () => expect(cutMenu('plain text')).toBeNull())
      test('no closing sentinel', () => expect(cutMenu(block().replace(MENU_END, ''))).toBeNull())
      test('no deck line', () => expect(cutMenu(`${MENU_START}\n在你第一次回覆\n${MENU_END}`)).toBeNull())
      test('broken JSON', () => expect(cutMenu(block('<!-- cortex:deck {broken -->'))).toBeNull())
      test('escaped angle brackets read back', () => {
        const deck = { ...DECK, batons: [{ label: 'a-->b<c', prompt: 'p' }] }
        expect(cutMenu(block(deckLine(deck)))?.deck.batons[0].label).toBe('a-->b<c')
      })
    })
    
    describe('deckPath', () => {
      test('one file per session under the plugin root', () =>
        expect(deckPath('/plug', 'abc-123')).toBe('/plug/.deck/abc-123.json'))
      test('an id that could leave the folder has no file', () => expect(deckPath('/plug', '../x')).toBeNull())
    })
    
    describe('parseDeck', () => {
      test('a version-1 deck', () => expect(parseDeck(JSON.stringify(DECK))?.handoff).toBe('h'))
      test('keeps the stamp', () => expect(parseDeck(JSON.stringify({ ...DECK, stamp: 's1' }))?.stamp).toBe('s1'))
      test('a deck without a stamp has none', () => expect(parseDeck(JSON.stringify(DECK))?.stamp).toBeUndefined())
      test('a stamp that is not a string', () =>
        expect(parseDeck(JSON.stringify({ ...DECK, stamp: 1 }))).toBeNull())
      test('another version', () => expect(parseDeck(JSON.stringify({ ...DECK, v: 2 }))).toBeNull())
      test('a malformed button', () =>
        expect(parseDeck(JSON.stringify({ ...DECK, actions: [{ label: 1, prompt: 'p' }] }))).toBeNull())
      test('a malformed status', () =>
        expect(parseDeck(JSON.stringify({ ...DECK, status: [{ text: 't', warn: 'yes' }] }))).toBeNull())
      test('a baton keeps its summary', () => {
        const batons = [{ label: 'line', summary: 'what it was', prompt: 'p5' }]
        expect(parseDeck(JSON.stringify({ ...DECK, batons }))?.batons[0].summary).toBe('what it was')
      })
      test('a malformed baton summary', () =>
        expect(parseDeck(JSON.stringify({ ...DECK, batons: [{ label: 'l', summary: 3, prompt: 'p' }] }))).toBeNull())
      test('not an object', () => expect(parseDeck('"text"')).toBeNull())
      test('an unknown source starts', () =>
        expect(parseDeck(JSON.stringify({ ...DECK, source: 'weird' }))?.source).toBe('startup'))
      test('a missing source starts', () => {
        const { source: _, ...rest } = DECK
        expect(parseDeck(JSON.stringify(rest))?.source).toBe('startup')
      })
    })
    
    describe('modes', () => {
      test('a compaction keeps the band down', () =>
        expect(modeForDeck({ ...DECK, source: 'compact' } as never)).toBe('hidden'))
      test('any other start shows the menu', () => {
        for (const source of ['startup', 'resume', 'clear']) {
          expect(modeForDeck({ ...DECK, source } as never)).toBe('menu')
        }
      })
      test('a submitted prompt folds the menu only', () => {
        expect(modeAfterSubmit('menu')).toBe('hidden')
        expect(modeAfterSubmit('handoff')).toBe('handoff')
        expect(modeAfterSubmit('hidden')).toBe('hidden')
      })
    })
    
    describe('reminderStep', () => {
      test('fires once per window, re-arms below the threshold', () => {
        let reminded = false
        const fires: boolean[] = []
        for (const pct of [69, 70, 75, 30, 72]) {
          const step = reminderStep(pct, reminded)
          reminded = step.reminded
          fires.push(step.fire)
        }
        expect(fires).toEqual([false, true, false, false, true])
        expect(HANDOFF_THRESHOLD).toBe(70)
      })
      test('a reading without a percentage changes nothing', () => {
        expect(reminderStep(undefined, true)).toEqual({ reminded: true, fire: false })
        expect(reminderStep(undefined, false)).toEqual({ reminded: false, fire: false })
      })
    })
    
  • hooks/deck-core.tsGitHub
    Read the script
    /**
     * @file Pure rules of the cortex deck: the fenced menu block that
     * session-start-inject.sh writes, the deck JSON on its first line, and the
     * band's state transitions. Nothing here touches `$`, so every rule is
     * tested without the engine (deck-core.test.ts).
     */
    import type { Deck, DeckBaton, DeckButton, DeckMode, DeckSource, DeckStatus } from '../types'
    
    /** Opening sentinel of the model-facing menu block. */
    export const MENU_START = '<!-- cortex:menu -->'
    /** Closing sentinel; the cut runs to the last one, so a quoted copy cannot end it early. */
    export const MENU_END = '<!-- /cortex:menu -->'
    const DECK_PREFIX = '<!-- cortex:deck '
    const DECK_SUFFIX = ' -->'
    /** Context use, in percent of the window, that raises the hand-off reminder. */
    export const HANDOFF_THRESHOLD = 70
    
    const SOURCES: readonly DeckSource[] = ['startup', 'resume', 'clear', 'compact']
    
    const isRecord = (x: unknown): x is Record<string, unknown> =>
      typeof x === 'object' && x !== null && !Array.isArray(x)
    
    const isButton = (x: unknown): x is DeckButton =>
      isRecord(x) && typeof x.label === 'string' && typeof x.prompt === 'string'
    
    const isBaton = (x: unknown): x is DeckBaton =>
      isRecord(x) && isButton(x) && (x.summary === undefined || typeof x.summary === 'string')
    
    const isStatus = (x: unknown): x is DeckStatus =>
      isRecord(x) && typeof x.text === 'string' && (x.warn === undefined || typeof x.warn === 'boolean')
    
    /**
     * Parses a deck's JSON text.
     * @param json the text between the deck line's prefix and suffix
     * @returns the deck, or null when the text is not a version-1 deck
     */
    export function parseDeck(json: string): Deck | null {
      let raw: unknown
      try {
        raw = JSON.parse(json)
      } catch {
        return null
      }
      if (!isRecord(raw) || raw.v !== 1) return null
      const { repo, handoff, status, actions, batons } = raw
      if (typeof repo !== 'string' || typeof handoff !== 'string') return null
      if (!Array.isArray(status) || !status.every(isStatus)) return null
      if (!Array.isArray(actions) || !actions.every(isButton)) return null
      if (!Array.isArray(batons) || !batons.every(isBaton)) return null
      const { stamp } = raw
      if (stamp !== undefined && typeof stamp !== 'string') return null
      const given = raw.source
      const source = SOURCES.find(s => s === given) ?? 'startup'
      return { v: 1, source, repo, handoff, status, actions, batons, ...(stamp === undefined ? {} : { stamp }) }
    }
    
    /**
     * Where session-start-inject.sh writes a session's deck: the file channel,
     * read where an organization's policy plugin keeps `session.append` from the
     * person's plugins and the context block never reaches the mod.
     * @param root the plugin's directory (`$.plugin.root`, the script's CLAUDE_PLUGIN_ROOT)
     * @param sessionId the session's id
     * @returns the file's path, or null for an id that is not a plain file name
     */
    export function deckPath(root: string, sessionId: string): string | null {
      if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) return null
      return `${root}/.deck/${sessionId}.json`
    }
    
    /** A text block with the menu cut out, and the deck the block carried. */
    export type Cut = { text: string; deck: Deck }
    
    /**
     * Cuts the fenced menu block out of one text block.
     *
     * The block runs from the first MENU_START to the last MENU_END and the
     * newline after it; the line right after MENU_START must be a version-1 deck.
     * @returns the cut, or null when anything is missing or malformed, so the
     *   caller passes the text on untouched and the model shows the text menu
     */
    export function cutMenu(text: string): Cut | null {
      const start = text.indexOf(MENU_START)
      if (start < 0) return null
      const end = text.lastIndexOf(MENU_END)
      if (end < start) return null
      const lineStart = start + MENU_START.length + 1
      if (text[lineStart - 1] !== '\n') return null
      const lineEnd = text.indexOf('\n', lineStart)
      if (lineEnd < 0 || lineEnd > end) return null
      const line = text.slice(lineStart, lineEnd)
      if (!line.startsWith(DECK_PREFIX) || !line.endsWith(DECK_SUFFIX)) return null
      const deck = parseDeck(line.slice(DECK_PREFIX.length, line.length - DECK_SUFFIX.length))
      if (deck === null) return null
      let cutEnd = end + MENU_END.length
      if (text[cutEnd] === '\n') cutEnd += 1
      return { text: text.slice(0, start) + text.slice(cutEnd), deck }
    }
    
    /** The band's mode once a new deck arrives: down after a compaction, else the menu. */
    export const modeForDeck = (deck: Deck): DeckMode => (deck.source === 'compact' ? 'hidden' : 'menu')
    
    /** The band's mode after any prompt is submitted: the menu folds, a reminder stays. */
    export const modeAfterSubmit = (mode: DeckMode): DeckMode => (mode === 'menu' ? 'hidden' : mode)
    
    /** One measurement's effect on the reminder flag. */
    export type ReminderStep = { reminded: boolean; fire: boolean }
    
    /**
     * Applies one context measurement to the reminder flag: fire on the first
     * reading at or over the threshold, re-arm once a reading drops below it (a
     * compaction), and ignore a reading without a percentage (a fresh window).
     */
    export function reminderStep(percent: number | undefined, reminded: boolean): ReminderStep {
      if (percent === undefined) return { reminded, fire: false }
      if (percent < HANDOFF_THRESHOLD) return { reminded: false, fire: false }
      return { reminded: true, fire: !reminded }
    }
    
  • hooks/deck.test.tsGitHub
    Read the script
    import type { On } from 'claude-code'
    import { describe, expect, mock, test } from 'claude-code/testing'
    import type { Engine } from 'claude-code/testing'
    
    const PROPS = { hasSurvey: false, isWorking: false, maxRows: 20 } as never
    const NO_BAND = 'no implementation for ui.render'
    
    const deckOf = (source: string, batons: object[] = [{ label: 'line-a', prompt: 'resume line-a' }]) => ({
      v: 1, source, repo: 'cortex',
      status: [{ text: '有 2 個 commit 尚未推送', warn: true }],
      actions: [
        { label: '載入記憶', prompt: 'load memory' },
        { label: '最近紀錄', prompt: 'recent raws' },
        { label: '處理待辦', prompt: 'process backlog' },
      ],
      batons,
      handoff: 'write a baton',
    })
    
    const hookText = (deck: unknown) =>
      '[Cortexes] front\n\n<!-- cortex:menu -->\n' +
      `<!-- cortex:deck ${JSON.stringify(deck)} -->\n` +
      '在你第一次回覆使用者時,呈現以下格式:\n<!-- /cortex:menu -->\nOTHER PLUGIN'
    
    /** Records each row's text as it reaches the engine, after the plugin's rewrite. */
    function recordRows(on: On): string[] {
      const rows: string[] = []
      on('session.append', ($, e, next) => {
        rows.push(e.message.content.map(b => (b.type === 'text' ? b.text : `[${b.type}]`)).join('|'))
        return next(e)
      })
      return rows
    }
    
    let rowId = 0
    /** Appends one row; nothing beneath the test stores rows, so the engine's refusal is expected. */
    async function append($: Engine, texts: string[], door: 'hook-context' | 'prompt' = 'hook-context') {
      const content = texts.map(text => ({ type: 'text', text }))
      const message = door === 'prompt'
        ? { type: 'user', role: 'user', content }
        : { type: 'attachment', name: 'hook_additional_context', content }
      const origin = door === 'prompt' ? { kind: 'person' } : { kind: 'hook' }
      try {
        await $.session.append({ door, origin, uuid: `row-${++rowId}`, message } as never)
      } catch {
        // "no implementation for session.append": the test stands for the engine
      }
    }
    
    /** Records submitted prompts and answers them as the engine would. */
    function recordSubmits(on: On): { text: string; asUser?: boolean }[] {
      const subs: { text: string; asUser?: boolean }[] = []
      on('prompt.submit', ($, e) => {
        subs.push({ text: e.text, asUser: (e.origin as { asUser?: boolean } | undefined)?.asUser })
        return { text: e.text }
      })
      return subs
    }
    
    function recordToasts(on: On): string[] {
      const toasts: string[] = []
      on('ui.toast', ($, e) => {
        toasts.push((e as { text: string }).text)
        return { value: undefined } as never
      })
      return toasts
    }
    
    function answerMeasure(on: On): void {
      on('session.measure', ($, e) => ({ changed: e.changed }))
    }
    
    const measure = ($: Engine, pct: number) =>
      $.session.measure({ context: { window: 200000, tokens: pct * 2000, percent: pct }, rateLimits: [], changed: [] } as never)
    
    const mountBand = ($: Engine, surface: 'terminal' | 'desktop' = 'terminal') =>
      $.ui.mount({ plugin: 'cortexes', surface, component: 'AbovePrompt', props: PROPS })
    
    describe('menu block', () => {
      test('a hook-context row loses only the fenced block', async ($, on) => {
        const rows = recordRows(on)
        await append($, [hookText(deckOf('startup'))])
        expect(rows).toEqual(['[Cortexes] front\n\nOTHER PLUGIN'])
      })
    
      test('the block is found in a later text block too', async ($, on) => {
        const rows = recordRows(on)
        await append($, ['OTHER PLUGIN FIRST', hookText(deckOf('startup'))])
        expect(rows).toEqual(['OTHER PLUGIN FIRST|[Cortexes] front\n\nOTHER PLUGIN'])
      })
    
      test('rows from other doors keep the block', async ($, on) => {
        const rows = recordRows(on)
        const text = hookText(deckOf('startup'))
        await append($, [text], 'prompt')
        expect(rows).toEqual([text])
      })
    
      test('a row whose deck is not a deck stays whole and draws no band', async ($, on) => {
        const rows = recordRows(on)
        const text = hookText({ ...deckOf('startup'), v: 2 })
        await append($, [text])
        expect(rows).toEqual([text])
        await expect(mountBand($)).rejects.toThrow(NO_BAND)
      })
    })
    
    describe('band', () => {
      for (const surface of ['terminal', 'desktop'] as const) {
        test(`draws the deck and sends a button's prompt as the user (${surface})`, async ($, on) => {
          recordRows(on)
          const subs = recordSubmits(on)
          await append($, [hookText(deckOf('startup'))])
          const ui = await mountBand($, surface)
          expect((await ui.find({ type: 'Text', text: 'cortexes' }))?.props.color).toBe('claude')
          expect((await ui.find({ type: 'Text', text: '· 有 2 個 commit 尚未推送' }))?.props.color).toBe('warning')
          expect(await ui.find({ type: 'Button', text: '接續交接 line-a' })).toBeDefined()
          await ui.press({ key: 'action-0' })
          expect(subs).toEqual([{ text: 'load memory', asUser: true }])
          await expect(mountBand($, surface)).rejects.toThrow(NO_BAND)
        })
      }
    
      test('the menu is a framed card of numbered buttons', async ($, on) => {
        recordRows(on)
        await append($, [hookText(deckOf('startup'))])
        const ui = await mountBand($)
        expect(await ui.drawn()).toMatchObject({ props: { borderStyle: 'round', borderColor: 'subtle' } })
        const hotkeys = await Promise.all(['action-0', 'action-1', 'action-2', 'baton-0']
          .map(async key => (await ui.find({ key }))?.props))
        expect(hotkeys.map(p => [p?.plain, p?.hotkey])).toEqual([[true, '1'], [true, '2'], [true, '3'], [true, '4']])
        expect((await ui.find({ key: 'hide' }))?.props).toMatchObject({ plain: true, dimColor: true })
        expect((await ui.find({ key: 'hide' }))?.props.hotkey).toBeUndefined()
      })
    
      test('a baton shows its summary dim beside the button', async ($, on) => {
        recordRows(on)
        const batons = [{ label: 'line-a', summary: 'what line-a was doing', prompt: 'resume line-a' }]
        await append($, [hookText(deckOf('startup', batons))])
        const summary = await (await mountBand($)).find({ type: 'Text', text: 'what line-a was doing' })
        expect(summary?.props).toMatchObject({ dimColor: true, wrap: 'truncate-end' })
      })
    
      test('a baton button sends its resu

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

@xbluesky
Ships withcortexes

Personal knowledge vault plugin for Claude Code — session recording, memory distillation, and hybrid semantic + BM25 retrieval.

Stars on GitHub

Momentum

stars in total
25
Star history of XBlueSky/cortexes on GitHub, a chart by star-history.com

More plugins that ship hooks.

i-have-adhd

i-have-adhd

A skill that shapes Claude Code's output for readers with ADHD: it leads with the next action, numbers multi-step work, restates state across turns, drops tangents, gives specific time estimates, and makes progress visible. Invoke it with /i-h.

1 skill1 hook60k
@ayghri@ayghri
Install Plugin
pua

pua

A Claude Code plugin that makes the assistant work harder when it stalls or repeats failures. It adds pressure-style coaching, plus skills for workplace reminders and senior engineer, CTO and team-lead modes for planning and execution.

12 skills+320k
@tanweai@tanweai
Install Plugin
claude-obsidian

claude-obsidian

Turns the sources you read, such as papers, articles and transcripts, into a linked wiki of Markdown notes in Obsidian. Claude keeps sources and citations so every summary can be traced back to its origin.

15 skills+216k
@agricidaniel@agricidaniel
Install Plugin
obsidian-second-brain

obsidian-second-brain

Gives Claude Code a persistent second brain stored as plain markdown in an Obsidian vault. Notes rewrite themselves as new sources arrive, and scheduled agents handle research synthesis and note capture.

1 skill+25.3k
@eugeniughelbur@eugeniughelbur
Install Plugin
arscontexta

arscontexta

A Claude Code plugin that builds a personal knowledge system through conversation, with skills for adding knowledge domains, checking vault health, getting methodology guidance, and evolving the system over time.

10 skills+23.5k
@agenticnotetaking@agenticnotetaking
Install Plugin
pro-workflow

pro-workflow

A Claude Code workflow plugin that learns from your corrections and keeps a self-correcting memory across sessions. It adds skills for agent teams, parallel worktree batch changes, bug capture as GitHub issues, project auto-setup and context compaction.

41 skills+32.9k
@rohitg00@rohitg00
Install Plugin
engram

engram

Helps you learn any topic with a first-principles curriculum, guided tutoring, and spaced review that schedules free-recall practice. Includes coaching for retention stats and study strategy.

3 skills+21.5k
@nagisanzenin@nagisanzenin
Install Plugin
hey

hey

A command line tool for HEY that lets you read and send email, manage contacts, labels, calendars, todos, habits, time tracking and journal entries from the terminal or from your coding agent.

1 skill1 hook423
@basecamp@basecamp
Install Plugin

Questions about the hooks in cortexes.

Which coding agents can use the hooks in cortexes?
The hooks in cortexes install in Claude Code and Cursor: each runs Claude Code hooks from .claude/settings.json, so copy the hooks there.