Skip to content
Development
Hook

Hooks

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

From plugin
model-routing
41 skill8 agents1 command2 hooks

Claude Code

> /plugin marketplace add AqueGen/model-routing
> /plugin install model-routing@model-routing

What fires, and when

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.

  • cat "${CLAUDE_PLUGIN_ROOT}/hooks/routing-anchor.md"
  • Matchesstartupnode "${CLAUDE_PLUGIN_ROOT}/hooks/update-check.mjs"

PostToolUse

  • MatchesAgent|Tasknode "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-counter.mjs"
Read hooks/hooks.json

Where it lives

  • hooks/dispatch-counter.mjsRunsGitHub
    Read the script
    #!/usr/bin/env node
    // Dispatch counter: logs every Agent-tool dispatch (PostToolUse hook) and
    // prints kept-off-strongest stats for a status line.
    //
    //   node dispatch-counter.mjs          <- hook mode: read event JSON on stdin, append
    //   node dispatch-counter.mjs stats    <- print "routed-down: N today · M 7d"
    //   node dispatch-counter.mjs report   <- per-agent dispatch breakdown
    //   node dispatch-counter.mjs tokens   <- real token volume per model from subagent transcripts
    //
    // Window flags (stats/report/tokens): --days N sizes the window (default 7),
    // --ago M shifts it back M days (--days 7 --ago 7 = the week before last
    // week's end); --session <family> scopes to sessions whose model matches
    // (e.g. "fable" when a fallback ladder mixes tiers into one window).
    // Dispatch history is retained 30 days; tokens reach as far back as
    // Claude Code keeps transcripts (cleanupPeriodDays).
    //
    // "Routed down" = the dispatch's effective model (explicit model param, else
    // the agent's frontmatter pin) ranks below the recorded session model. Entries
    // missing either side fall back to a cheap-agent/cheap-tier heuristic. Counts
    // dispatches, not tokens - honest bookkeeping, no dollar fiction.
    // Each entry also records the session's effort level and which source it came
    // from - CLAUDE_CODE_EFFORT_LEVEL, else the settings cascade, else the model's
    // documented default - so the report can show the second knob: dispatches on
    // agent types with no known pin inherit it, pinned agents do not.
    // Log lives in <config>/model-routing/dispatches.jsonl and self-prunes to 30d.
    
    import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs";
    import { dirname, join, resolve } from "node:path";
    import { fileURLToPath } from "node:url";
    import { homedir } from "node:os";
    
    const DAY_MS = 24 * 60 * 60 * 1000;
    const RETENTION_MS = 30 * DAY_MS;
    
    // Stamped on every report so a pasted screenshot answers "which version wrote
    // this" without a second round trip - the question that costs the most time
    // when someone reports a report that looks wrong. Read from the manifest next
    // to this script rather than CLAUDE_PLUGIN_ROOT, which the hook sets but a
    // direct `node hooks/dispatch-counter.mjs report` does not. Unreadable manifest
    // degrades to an unlabelled header; a version stamp must never break a report.
    const PLUGIN_VERSION = (() => {
      try {
        const here = dirname(fileURLToPath(import.meta.url));
        return JSON.parse(readFileSync(join(here, "..", ".claude-plugin", "plugin.json"), "utf-8")).version ?? null;
      } catch {
        return null;
      }
    })();
    const versionSuffix = PLUGIN_VERSION ? ` - model-routing ${PLUGIN_VERSION}` : "";
    
    // Window flags: --days N (size, default 7) and --ago M (shift back M days).
    // Bad values fall back to the default rather than erroring - a stats tool
    // must never be harder to run than the thing it measures.
    function windowFromArgs(argv) {
      const flag = (name) => {
        const i = argv.indexOf(name);
        const v = i >= 0 ? Number(argv[i + 1]) : NaN;
        return Number.isFinite(v) && v >= 0 ? v : null;
      };
      const days = flag("--days") ?? 7;
      const ago = flag("--ago") ?? 0;
      const end = Date.now() - ago * DAY_MS;
      return { start: end - days * DAY_MS, end, days, ago };
    }
    
    // --session <family> scopes a report to sessions whose model matches the
    // substring (e.g. "fable", "opus") - useful when a fallbackModel ladder or
    // manual /model switches mix session tiers inside one window and you only
    // want the situation that matches your default. Case-insensitive.
    function sessionFilterFromArgs(argv) {
      const i = argv.indexOf("--session");
      return i >= 0 && argv[i + 1] ? String(argv[i + 1]).toLowerCase() : null;
    }
    // Frontmatter pins of the bundled agents, model and effort together. A bare
    // dispatch (no model param) still runs on the pinned model, so classification
    // must resolve through this table or bare implementer dispatches (pin=sonnet
    // since 0.6.0) get miscounted as session-tier work; the effort column is the
    // second cost knob, which moves cost as hard as tier does. One table rather
    // than two because they are two columns of one fact - the agent's frontmatter -
    // and asking "is this agent pinned" through separate tables let them disagree.
    // Keep in sync with agents/*.md; one sync test guards both columns.
    const AGENT_PINS = {
      "model-routing:scout": { model: "sonnet", effort: "low" },
      "model-routing:surveyor": { model: "haiku", effort: "medium" },
      "model-routing:test-runner": { model: "haiku", effort: "low" },
      "model-routing:e2e-runner": { model: "sonnet", effort: "medium" },
      "model-routing:verifier": { model: "haiku", effort: "medium" },
      "model-routing:implementer": { model: "sonnet", effort: "medium" },
      "model-routing:reviewer": { model: "opus", effort: "high" },
      "model-routing:task-reviewer": { model: "sonnet", effort: "high" },
    };
    // Model pins for OTHER plugins' agents, curated by hand from their frontmatter
    // the same way AGENT_PINS is - the difference is these files live outside this
    // repo, so nothing here can assert they stay in sync, and there is no effort
    // column because an agent's effort pin is not visible from outside its own
    // plugin. Every entry exists because it showed up as noise: a bare dispatch of
    // that exact agent type, on a session strong enough to make "did this inherit
    // the session model" ambiguous, when the frontmatter already answers the
    // question. This is deliberately small - add an entry when one causes a false
    // positive in YOUR logs, not preemptively for every plugin that might.
    //
    // A pin recorded here can go stale, and this is only about `report` (see
    // ownPinnedModel below for why `tokens` never trusts this table): the two
    // caveman entries ship a documented runtime override -
    // CAVECREW_REVIEWER_MODEL / CAVECREW_INVESTIGATOR_MODEL - that PATCHES the
    // installed agent's own frontmatter file, persisting until the plugin i
  • hooks/dispatch-counter.test.mjsGitHub
    Read the script
    // Smoke tests for dispatch-counter.mjs. Run: node --test hooks/
    // Tests drive the CLI end-to-end with CLAUDE_CONFIG_DIR pointed at a temp
    // dir, so no exports or refactors of the script are needed.
    
    import { test } from "node:test";
    import assert from "node:assert/strict";
    import { execFileSync } from "node:child_process";
    import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rmSync } from "node:fs";
    import { join, dirname } from "node:path";
    import { tmpdir } from "node:os";
    import { fileURLToPath } from "node:url";
    
    const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "dispatch-counter.mjs");
    
    function run(args, configDir, stdin, extraEnv, cwd) {
      return execFileSync(process.execPath, [SCRIPT, ...args].filter(Boolean), {
        // CLAUDE_CODE_SUBAGENT_MODEL, CLAUDE_CODE_SUBAGENT_MODEL_FORCE and
        // CLAUDE_CODE_EFFORT_LEVEL are blanked by default so a developer's own
        // overrides cannot leak into the hermetic tests; set any via extraEnv.
        env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, CLAUDE_CODE_SUBAGENT_MODEL: "", CLAUDE_CODE_SUBAGENT_MODEL_FORCE: "", CLAUDE_CODE_EFFORT_LEVEL: "", ...(extraEnv ?? {}) },
        // Default the working directory to the temp config dir, never the directory
        // the suite happens to run from: the hook reads <cwd>/.claude/settings*.json
        // for the session effort, so an ambient cwd would let a real settings file
        // two levels up decide a test outcome. Tests that need a specific project
        // cascade pass their own cwd.
        cwd: cwd ?? configDir,
        input: stdin ?? "",
        encoding: "utf-8",
      });
    }
    
    function freshConfigDir() {
      return mkdtempSync(join(tmpdir(), "mr-test-"));
    }
    
    function writeLog(configDir, entries) {
      const dir = join(configDir, "model-routing");
      mkdirSync(dir, { recursive: true });
      writeFileSync(join(dir, "dispatches.jsonl"), entries.map((e) => JSON.stringify(e)).join("\n") + "\n");
    }
    
    test("report with no log explains itself instead of printing nothing", () => {
      const cfg = freshConfigDir();
      try {
        const out = run(["report"], cfg);
        assert.match(out, /No dispatches logged/);
        assert.match(out, /PostToolUse hook/);
      } finally { rmSync(cfg, { recursive: true, force: true }); }
    });
    
    test("stats with no log prints a no-data marker", () => {
      const cfg = freshConfigDir();
      try {
        assert.equal(run(["stats"], cfg), "routed-down: no data (7d)");
      } finally { rmSync(cfg, { recursive: true, force: true }); }
    });
    
    test("report groups by tier and never ranks unknown models", () => {
      const cfg = freshConfigDir();
      const now = Date.now();
      writeLog(cfg, [
        // sonnet from an opus session: routed down.
        { ts: now, agent: "general-purpose", model: "sonnet", session: "claude-opus-4-8" },
        // opus from an opus session: at tier, not down.
        { ts: now, agent: "model-routing:implementer", model: "opus", session: "claude-opus-4-8" },
        // future model family: tier unknown - must NOT count as routed down.
        { ts: now, agent: "general-purpose", model: "zephyr-1", session: "claude-opus-4-8" },
      ]);
      try {
        const out = run(["report"], cfg);
        // Unknown-tier entries are excluded from the denominator - one exotic
        // model must not drag the routed-down share down.
        assert.match(out, /1 of 2 comparable dispatches \(50%\) ran on a lower tier/);
        assert.match(out, /1 not tier-comparable excluded/);
        // Unknown-model rows land in their own section - honest unknown,
        // not silently counted as routed down or at-tier.
        assert.match(out, /Not tier-comparable[\s\S]*general-purpose \(model=zephyr-1\)/);
        assert.match(out, /Ran at the session tier[\s\S]*implementer \(model=opus\)/);
      } finally { rmSync(cfg, { recursive: true, force: true }); }
    });
    
    test("bare pinned agents classify by their frontmatter pin", () => {
      const cfg = freshConfigDir();
      const now = Date.now();
      writeLog(cfg, [
        // implementer pins sonnet: a bare dispatch from an opus session ran
        // sonnet, so it is routed down even without an explicit model param.
        { ts: now, agent: "model-routing:implementer", model: null, session: "claude-opus-4-8" },
        // reviewer pins opus: bare on an opus session stays at the session tier.
        { ts: now, agent: "model-routing:reviewer", model: null, session: "claude-opus-4-8" },
      ]);
      try {
        const out = run(["report"], cfg);
        assert.match(out, /1 of 2 dispatches \(50%\) ran on a lower tier/);
        assert.match(out, /Ran cheaper[\s\S]*implementer \(pin=sonnet\)/);
        assert.match(out, /Ran at the session tier[\s\S]*reviewer \(pin=opus\)/);
      } finally { rmSync(cfg, { recursive: true, force: true }); }
    });
    
    test("a curated foreign agent pin classifies like a bundled one, not a leak", () => {
      const cfg = freshConfigDir();
      const now = Date.now();
      // codex:codex-rescue pins sonnet in its own frontmatter (openai-codex
      // plugin) - this plugin cannot see that file, but FOREIGN_AGENT_PINS
      // records it by hand because it was the exact agent causing false leak
      // warnings before this test existed. A bare dispatch on an opus session
      // should route down on the strength of that known pin, the same as a
      // bundled agent, and must not count toward "tier leaks" at all.
      writeLog(cfg, [
        { ts: now, agent: "codex:codex-rescue", model: null, session: "claude-opus-5" },
        // A genuinely unpinned type, for contrast - this one SHOULD leak.
        { ts: now, agent: "general-purpose", model: null, session: "claude-opus-5" },
      ]);
      try {
        const out = run(["report"], cfg);
        assert.match(out, /1 of 2 dispatches \(50%\) ran on a lower tier/);
        assert.match(out, /Ran cheaper[\s\S]*codex:codex-rescue \(pin=sonnet\)/);
        assert.match(out, /Tier leaks: 1 of 1 dispatches on agent types with no MODEL pin this plugin knows \(100%\)/);
      } finally { rmSync(cfg, { recursive: true, force: true }); }
    });
    
    test("a foreign-pinned agent dispatched below its own pin is still called out", () => {
      const cfg = freshConfigDir();
      const no
  • hooks/update-check.mjsRunsGitHub
    Read the script
    #!/usr/bin/env node
    // SessionStart hook: print a one-line notice when a newer plugin version has
    // been published, so marketplace installs (which never auto-update) hear about
    // it. Checks the network at most once per 24h via a timestamp cache; every
    // failure path is silent - a version check must never break session start.
    
    import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
    import { join } from "node:path";
    import { homedir } from "node:os";
    
    const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
    const FETCH_TIMEOUT_MS = 3000;
    const MANIFEST_URL =
      process.env.MODEL_ROUTING_UPDATE_URL ??
      "https://raw.githubusercontent.com/AqueGen/model-routing/main/.claude-plugin/plugin.json";
    const RELEASES_URL = "https://github.com/AqueGen/model-routing/releases";
    
    function parseVersion(v) {
      const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(v ?? "");
      return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
    }
    
    function isNewer(latest, installed) {
      const a = parseVersion(latest);
      const b = parseVersion(installed);
      if (!a || !b) return false;
      for (let i = 0; i < 3; i++) {
        if (a[i] !== b[i]) return a[i] > b[i];
      }
      return false;
    }
    
    async function fetchLatestVersion() {
      const ctrl = new AbortController();
      const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
      try {
        const res = await fetch(MANIFEST_URL, { signal: ctrl.signal });
        if (!res.ok) return null;
        const manifest = await res.json();
        return parseVersion(manifest.version) ? manifest.version : null;
      } catch {
        return null;
      } finally {
        clearTimeout(timer);
      }
    }
    
    let installed;
    try {
      const manifestPath = join(process.env.CLAUDE_PLUGIN_ROOT ?? "", ".claude-plugin", "plugin.json");
      installed = JSON.parse(readFileSync(manifestPath, "utf-8")).version;
    } catch {
      process.exit(0);
    }
    
    const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
    const cacheDir = join(configDir, "model-routing");
    const cachePath = join(cacheDir, "update-check.json");
    
    let cache = null;
    try {
      cache = JSON.parse(readFileSync(cachePath, "utf-8"));
    } catch {
      // no cache yet
    }
    
    const now = Date.now();
    const stale = !cache || typeof cache.checkedAt !== "number" || now - cache.checkedAt >= CHECK_INTERVAL_MS;
    if (stale) {
      const latest = await fetchLatestVersion();
      // Stamp checkedAt even on failure so an offline machine is not probed on
      // every session start; keep the previously known latest in that case.
      cache = { checkedAt: now, latest: latest ?? cache?.latest ?? null };
      try {
        mkdirSync(cacheDir, { recursive: true });
        writeFileSync(cachePath, JSON.stringify(cache));
      } catch {
        // read-only home - notice still works off the in-memory cache
      }
    }
    
    if (cache && isNewer(cache.latest, installed)) {
      // systemMessage is the only hook output field documented as shown to the USER.
      // Plain stdout on SessionStart is added to Claude's context instead: the model
      // cannot update the plugin, and whether it relays the line is up to it - so a
      // plain-text notice reaches the person only by luck. Valid JSON on stdout is
      // parsed as hook output rather than injected as context.
      // Both commands are needed and in this order: nothing refreshes the
      // marketplace catalog on its own, so `plugin update` alone can reinstall the
      // version already in the stale catalog.
      console.log(
        JSON.stringify({
          systemMessage:
            `model-routing ${installed} installed, ${cache.latest} available. ` +
            `Update: claude plugin marketplace update model-routing && claude plugin update model-routing, then restart. ` +
            `Changes: ${RELEASES_URL}`,
        })
      );
    }
    
  • hooks/update-check.test.mjsGitHub
    Read the script
    // Smoke tests for update-check.mjs. Run: node --test hooks/
    // Tests drive the script end-to-end: CLAUDE_PLUGIN_ROOT points at a temp
    // plugin dir, CLAUDE_CONFIG_DIR at a temp cache dir, and the remote manifest
    // is served by a throwaway local http server via MODEL_ROUTING_UPDATE_URL.
    
    import { test } from "node:test";
    import assert from "node:assert/strict";
    import { execFile, execFileSync } from "node:child_process";
    import { promisify } from "node:util";
    import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
    import { join, dirname } from "node:path";
    import { tmpdir } from "node:os";
    import { fileURLToPath } from "node:url";
    import { createServer } from "node:http";
    
    const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "update-check.mjs");
    const DAY_MS = 24 * 60 * 60 * 1000;
    
    function makePluginRoot(version) {
      const root = mkdtempSync(join(tmpdir(), "mr-upd-root-"));
      mkdirSync(join(root, ".claude-plugin"));
      writeFileSync(join(root, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "model-routing", version }));
      return root;
    }
    
    function envFor(pluginRoot, configDir, updateUrl) {
      return {
        ...process.env,
        CLAUDE_PLUGIN_ROOT: pluginRoot,
        CLAUDE_CONFIG_DIR: configDir,
        MODEL_ROUTING_UPDATE_URL: updateUrl,
      };
    }
    
    // Sync runner for tests with no live server. Server-backed tests MUST use
    // runAsync: execFileSync blocks the parent's event loop, so the in-process
    // http server would never answer and the child would silently time out.
    function run(pluginRoot, configDir, updateUrl) {
      return execFileSync(process.execPath, [SCRIPT], { env: envFor(pluginRoot, configDir, updateUrl), encoding: "utf-8" });
    }
    
    const execFileAsync = promisify(execFile);
    async function runAsync(pluginRoot, configDir, updateUrl) {
      const { stdout } = await execFileAsync(process.execPath, [SCRIPT], {
        env: envFor(pluginRoot, configDir, updateUrl),
        encoding: "utf-8",
      });
      return stdout;
    }
    
    function serveOnce(body) {
      return new Promise((resolve) => {
        const server = createServer((_req, res) => {
          res.setHeader("content-type", "application/json");
          res.end(body);
        });
        server.listen(0, "127.0.0.1", () => {
          resolve({ url: `http://127.0.0.1:${server.address().port}/plugin.json`, close: () => server.close() });
        });
      });
    }
    
    function readCache(configDir) {
      return JSON.parse(readFileSync(join(configDir, "model-routing", "update-check.json"), "utf-8"));
    }
    
    test("prints a notice and writes the cache when the remote is newer", async () => {
      const { url, close } = await serveOnce(JSON.stringify({ version: "9.9.9" }));
      try {
        const configDir = mkdtempSync(join(tmpdir(), "mr-upd-cfg-"));
        const out = await runAsync(makePluginRoot("1.0.0"), configDir, url);
        // The notice has to reach the USER, and systemMessage is the only output
        // field documented as doing that - plain stdout on SessionStart goes to
        // Claude's context, where reaching the person depends on the model relaying
        // it. Parsing here is the assertion: a bare string would throw.
        const notice = JSON.parse(out).systemMessage;
        assert.match(notice, /1\.0\.0 installed, 9\.9\.9 available/);
        // Both commands in full, catalog refresh first: nothing refreshes the catalog
        // on its own, so `plugin update` alone can reinstall the version already in a
        // stale one. The `claude plugin ` prefix is part of what must be asserted -
        // without it the assertion passes on a command nobody can paste.
        assert.match(
          notice,
          /claude plugin marketplace update model-routing && claude plugin update model-routing/
        );
        // README promises the release notes link, so the notice owes it.
        assert.match(notice, /https:\/\/github\.com\/AqueGen\/model-routing\/releases/);
        assert.equal(readCache(configDir).latest, "9.9.9");
      } finally {
        close();
      }
    });
    
    test("stays silent when up to date", async () => {
      const { url, close } = await serveOnce(JSON.stringify({ version: "1.0.0" }));
      try {
        const out = await runAsync(makePluginRoot("1.0.0"), mkdtempSync(join(tmpdir(), "mr-upd-cfg-")), url);
        assert.equal(out, "");
      } finally {
        close();
      }
    });
    
    test("uses a fresh cache without touching the network", async () => {
      // A live server that answers DIFFERENTLY from the cache is what makes this
      // test able to fail. A dead port cannot: the fetch would return null, the
      // cached latest would be restored, and the output would be identical whether
      // the freshness check ran or not - so the assertion held while the behaviour
      // it names was gone. The untouched checkedAt is the second half of the proof.
      const { url, close } = await serveOnce(JSON.stringify({ version: "9.9.9" }));
      try {
        const configDir = mkdtempSync(join(tmpdir(), "mr-upd-cfg-"));
        mkdirSync(join(configDir, "model-routing"), { recursive: true });
        const checkedAt = Date.now();
        writeFileSync(
          join(configDir, "model-routing", "update-check.json"),
          JSON.stringify({ checkedAt, latest: "2.0.0" })
        );
        const out = await runAsync(makePluginRoot("1.0.0"), configDir, url);
        assert.match(out, /1\.0\.0 installed, 2\.0\.0 available/);
        assert.doesNotMatch(out, /9\.9\.9/, "a fresh cache must not be refreshed from the network");
        assert.equal(readCache(configDir).checkedAt, checkedAt, "checkedAt must be left alone");
      } finally {
        close();
      }
    });
    
    test("a stale cache is refreshed from the network", async () => {
      const { url, close } = await serveOnce(JSON.stringify({ version: "9.9.9" }));
      try {
        const configDir = mkdtempSync(join(tmpdir(), "mr-upd-cfg-"));
        mkdirSync(join(configDir, "model-routing"), { recursive: true });
        writeFileSync(
          join(configDir, "model-routing", "update-check.json"),
          JSON.stringify({ checkedAt: Date.now() - 2 * DAY_MS, latest: "2.0.0" })
        );
        const out = await runAsync(makePluginRoot("1.0.0"), configDir, url);
        assert.match(out, /1\.0\.0 installed,

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.

@aquegen
Ships withmodel-routing

Tiered model routing for Claude Code token economy: **the strongest model thinks, cheaper models grind.** Planning and architecture stay in your main session on the best model you have.

41 skill8 agents
Get the whole plugin

Stars on GitHub

Momentum

stars in total
4
Star history of AqueGen/model-routing on GitHub, a chart by star-history.com

More plugins that ship hooks.

superpowers

superpowers

A development methodology for coding agents that makes the agent brainstorm before building, follow plans, run parallel work and finish branches in a disciplined way. You get fewer skipped steps and more consistent results from your coding assistant.

15 skills1 hook311k
@obra@obra
Install Plugin
ponytail

ponytail

A Claude Code plugin with skills for auditing a whole repo, reviewing a change, listing deferred shortcut comments, and showing measured savings and a quick command reference.

6 skills10 hooks188k
@dietrichgebert@dietrichgebert
Install Plugin
agent-skills

agent-skills

Walks a software change from a written spec through design, testing, code review, CI/CD, and release, applying the matching engineering skill at each stage. Useful for developers using Claude Code on full development workflows.

25 skills+3112k
@addyosmani@addyosmani
Install Plugin
planning-with-files

planning-with-files

Persistent file-based planning for AI coding agents on long-running, multi-step tasks. It keeps task_plan.md, findings.md and progress.md on disk, so work can resume after /clear or context compaction.

6 skills+228k
@othmanadi@othmanadi
Install Plugin
gsd-core

gsd-core

A spec-driven development workflow for Claude Code that guides a project through planning, phases, milestone audits and fixes. Includes skills to generate tests from UAT criteria and to audit completed work.

72 skills+311k
@open-gsd@open-gsd
Install Plugin
superpowers-zh

superpowers-zh

Chinese-language skill pack for Claude Code that adds workflow guidance for brainstorming, TDD, debugging, and code review, plus Chinese conventions for commits, documentation, and domestic Git platforms like Gitee and Coding.net.

21 skills1 hook8.5k
@jnmetacode@jnmetacode
Install Plugin
socraticode

socraticode

Gives Claude Code semantic search, dependency graph and architecture context over a codebase, so an AI assistant can explore and understand a project's code. Includes skills for indexing a project and managing its file watching.

2 skills+33.4k
@giancarloerra@giancarloerra
Install Plugin
cocoindex-code

cocoindex-code

A lightweight code search engine that uses AST-based semantic search with local embeddings and incremental indexing. It helps Claude Code users find code faster and more efficiently.

1 skill1 MCP+13k
@cocoindex-io@cocoindex-io
Install Plugin

Questions about the hooks in model-routing.

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