Skip to content
Development
Hook

Hooks

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

From plugin
honey
22314 skills3 agents3 hooks
Install
> /plugin marketplace add Green-PT/honey-for-devs
> /plugin install honey@greenpt

Ships with honey. Installing the plugin gets these hooks.

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.

  • node -e "require('module').runMain(process.argv[1]=process.argv[1].split(String.fromCharCode(92)).join('/'))" "${CLAUDE_PLUGIN_ROOT}/hooks/honey-session.js"

SubagentStart

  • node -e "require('module').runMain(process.argv[1]=process.argv[1].split(String.fromCharCode(92)).join('/'))" "${CLAUDE_PLUGIN_ROOT}/hooks/honey-subagent.js"

PostToolUse

  • MatchesBashnode -e "require('module').runMain(process.argv[1]=process.argv[1].split(String.fromCharCode(92)).join('/'))" "${CLAUDE_PLUGIN_ROOT}/hooks/logcompress-hook.js"
Read hooks/hooks.json

Where it lives

  • hooks/eco.jsGitHub
    Read the script
    #!/usr/bin/env node
    // Faithful port of EcoLogits v0.8.2 LLM impact model (genai-impact/ecologits, MIT).
    // Mean-value path only — we want one number for the badge, not the +/-1.96 sigma band.
    // Grid intensity comes from eco-config.json so the estimate reflects where Claude
    // actually runs (AWS Trainium, PA/IN/MS ~500 gCO2/kWh), not EcoLogits' world mix.
    //
    // impacts() is the verbatim single-stream (batch-size-1) EcoLogits figure — an
    // upper bound that assigns one request the whole GPU set for the full latency.
    // estimate() divides that ceiling by eco-config.json's serving_concurrency to
    // report realistic SERVED (continuously-batched) impact, and also returns the
    // untouched ceiling. See the _serving_note in eco-config.json for the rationale.
    // For the authoritative figure (embodied + ADPe + primary energy) run the real
    // package via scripts/eco_report.py — this JS exists to keep the statusline fast
    // and dependency-free.
    "use strict";
    
    const fs = require("fs");
    const path = require("path");
    
    // EcoLogits 0.8.2 constants — units: kWh, kgCO2eq, seconds, GB.
    // These constants and the per-request impact methodology are derived from
    // EcoLogits (genai-impact/ecologits) and are licensed MPL-2.0, not MIT.
    // See NOTICE. Source: https://github.com/genai-impact/ecologits
    const Q_BITS = 4;
    const E_ALPHA = 8.91e-8, E_BETA = 1.43e-6; // GPU energy/token: alpha*activeB + beta
    const L_ALPHA = 8.02e-4, L_BETA = 2.23e-2; // GPU latency/token: alpha*activeB + beta
    const GPU_MEM = 80, GPU_EMB_GWP = 143;
    const SRV_GPUS = 8, SRV_POWER = 1, SRV_EMB_GWP = 3000;
    const LIFETIME = 5 * 365 * 24 * 3600;
    const PUE = 1.2;
    
    function loadConfig() {
      const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, "eco-config.json"), "utf8"));
      cfg._registry = JSON.parse(
        fs.readFileSync(path.join(__dirname, "eco-models.json"), "utf8")
      ).models;
      return cfg;
    }
    
    // Resolve a model id to {active, total, provider} using EcoLogits' registry:
    // exact name -> else first alias substring -> else default_alias. Params are
    // always the registry's, never hand-typed.
    function resolveParams(model, cfg) {
      const reg = cfg._registry;
      const id = String(model || "");
      if (reg[id]) return reg[id];
      const lid = id.toLowerCase();
      for (const a of cfg.aliases) {
        if (a.match.some((s) => lid.includes(s)) && reg[a.registry]) return reg[a.registry];
      }
      return reg[cfg.default_alias];
    }
    
    // EcoLogits mean impacts for one generation. grid in kgCO2eq/kWh. request_latency=inf.
    function impacts(activeB, totalB, outTokens, grid) {
      const gpuEnergy = outTokens * (E_ALPHA * activeB + E_BETA); // kWh, single GPU
      const latency = outTokens * (L_ALPHA * activeB + L_BETA); // s
      const gpuCount = Math.ceil((1.2 * totalB * Q_BITS / 8) / GPU_MEM);
      const serverEnergy = (latency / 3600) * SRV_POWER * (gpuCount / SRV_GPUS);
      const energyKwh = PUE * (serverEnergy + gpuCount * gpuEnergy);
      const embGwp = (latency / LIFETIME) *
        ((gpuCount / SRV_GPUS) * SRV_EMB_GWP + gpuCount * GPU_EMB_GWP);
      return { energyKwh, gco2: (energyKwh * grid + embGwp) * 1000 };
    }
    
    // Grid follows the model's provider (Anthropic/AWS, OpenAI/Azure, Google/GCP).
    function gridFor(provider, cfg) {
      const g = cfg.grids_gco2_per_kwh;
      return (g[provider] != null ? g[provider] : g.default) / 1000; // -> kgCO2eq/kWh
    }
    
    // Served estimate: the faithful single-stream ceiling divided by serving_concurrency
    // (continuous-batching amortization). Returns the served figure as `gco2` plus the
    // untouched single-stream `gco2Ceiling` and the `concurrency` used.
    function estimate(model, outTokens, cfg) {
      cfg = cfg || loadConfig();
      const p = resolveParams(model, cfg);
      const B = cfg.serving_concurrency > 0 ? cfg.serving_concurrency : 1;
      const ceiling = impacts(p.active, p.total, outTokens, gridFor(p.provider, cfg));
      return {
        energyKwh: ceiling.energyKwh / B,
        gco2: ceiling.gco2 / B,
        gco2Ceiling: ceiling.gco2,
        concurrency: B,
      };
    }
    
    // Saved CO2/$ multiplier: a token reduction R implies baseline = actual/(1-R),
    // so savings scale by R/(1-R). Guard R>=1 (a misconfig) to avoid Infinity/NaN.
    function savingsFactor(cfg, mode) {
      const R = (cfg.savings_vs_baseline && cfg.savings_vs_baseline[mode]) || 0;
      return R < 1 ? R / (1 - R) : 0;
    }
    
    // The savings ratio WITH its provenance. The number is a modelled counterfactual from a
    // committed bench stamp — it was never measured for the session being reported — so this
    // returns the label callers must print alongside it.
    //
    // Returns null when no committed stamp covers the session's model. A tool that reports
    // its own savings against a counterfactual it never ran is grading its own homework;
    // no number is the honest output there, not an extrapolated one.
    function savingsInfo(cfg, mode, model) {
      const prov = cfg.savings_provenance || {};
      const byModel = prov.by_model || {};
      const key = model ? Object.keys(byModel).find((k) => String(model).toLowerCase().includes(k)) : null;
      if (!key) return null;
    
      const src = byModel[key];
      const scale = (cfg.savings_vs_baseline || {})[mode];
      const full = (cfg.savings_vs_baseline || {}).full;
      if (!scale || !full) return null;
    
      const R = src.ratio * (scale / full); // lite/ultra keep their historical ratio to full
      const measured = mode === prov.measured_mode;
      return {
        R,
        k: R < 1 ? R / (1 - R) : 0,
        measured,
        stamp: src.stamp || prov.stamp,
        model: src.model,
        p: src.p,
        label: measured
          ? `modeled from ${src.stamp || prov.stamp} (${src.model}, n=${src.n ?? prov.n}, p=${src.p}) — not measured for this session`
          : `extrapolated from the '${prov.measured_mode}' figure in ${src.stamp || prov.stamp} — not measured for this session, and this mode was never benchmarked`,
      };
    }
    
    module.exports = { loadConfig, estimate, savingsFactor, savingsInfo };
    
  • hooks/honey-session.jsRunsGitHub
    Read the script
    #!/usr/bin/env node
    // SessionStart hook: if Honey is active, inject a short always-on directive so
    // the skill applies reflexively without the user re-invoking it. Kept brief on
    // purpose — the full skill lives in skills/honey/SKILL.md; re-injecting it every
    // session would itself burn the tokens Honey exists to save.
    // Also warns (once per version, via systemMessage) when the Bash log compressor
    // is inert: old node on the hook PATH, or a Claude Code build affected by
    // anthropics/claude-code#68951 (updatedToolOutput ignored for built-in Bash).
    // Must stay parseable on old node: no `??`, no `?.` — this file IS the warning path.
    "use strict";
    
    const fs = require("fs");
    const os = require("os");
    const path = require("path");
    
    // anthropics/claude-code#68951 — PostToolUse updatedToolOutput ignored for Bash.
    // Regression surface is v2.1.121 (where the field shipped for built-in tools).
    // When upstream ships a fix, set CCR_FIXED_IN to that version.
    const CCR_BROKEN_SINCE = "2.1.121";
    const CCR_FIXED_IN = null;
    
    function claudeCodeVersion(env) {
      let m = (env.CLAUDE_CODE_VERSION || "").match(/^\d+\.\d+\.\d+/);
      if (m) return m[0];
      m = (env.AI_AGENT || "").match(/claude-code_(\d+)-(\d+)-(\d+)/);
      if (m) return m[1] + "." + m[2] + "." + m[3];
      m = (env.CLAUDE_CODE_EXECPATH || "").match(/claude-code[\/\\](\d+\.\d+\.\d+)[\/\\]/);
      if (m) return m[1];
      return null;
    }
    
    function cmpVersions(a, b) {
      const pa = a.split(".").map(Number);
      const pb = b.split(".").map(Number);
      for (let i = 0; i < 3; i++) if (pa[i] !== pb[i]) return pa[i] - pb[i];
      return 0;
    }
    
    // null version → unknown build → assume fine (README covers it); don't cry wolf.
    function ccrInert(version) {
      if (!version) return false;
      if (cmpVersions(version, CCR_BROKEN_SINCE) < 0) return false;
      return !CCR_FIXED_IN || cmpVersions(version, CCR_FIXED_IN) < 0;
    }
    
    function main() {
      const DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
    
      let mode = null;
      try {
        mode = fs.readFileSync(path.join(DIR, ".honey-active"), "utf8").trim() || null;
      } catch (e) {}
    
      if (!mode || mode === "off") process.exit(0);
    
      // Ledger for `honey-usage --savings`: one line per Honey session start, so
      // savings can later be claimed only for sessions verifiably run under Honey
      // (and in which mode). Append-only; never let it break the hook.
      try {
        var hookInput = JSON.parse(fs.readFileSync(0, "utf8"));
        if (hookInput && hookInput.transcript_path) {
          fs.appendFileSync(
            path.join(DIR, ".honey-usage-ledger.jsonl"),
            JSON.stringify({ ts: Date.now(), transcript_path: hookInput.transcript_path, mode: mode }) + "\n"
          );
        }
      } catch (e) {}
    
      const warnings = [];
      const nodeMajor = parseInt(process.versions.node, 10);
      if (nodeMajor < 14) {
        warnings.push(
          "honey: hooks are running with node " + process.versions.node + " (" +
          process.execPath + ") but need Node >= 14 — the Bash log compressor is " +
          "disabled. Desktop-app sessions use the launchd PATH, not your shell profile."
        );
      }
      const cc = claudeCodeVersion(process.env);
      if (ccrInert(cc)) {
        warnings.push(
          "honey: Claude Code " + cc + " ignores PostToolUse updatedToolOutput for the " +
          "built-in Bash tool (anthropics/claude-code#68951), so the Bash log compressor " +
          "is inert on this version. Piping through `eson crush` still works."
        );
      }
    
      const out = {
        hookSpecificOutput: {
          hookEventName: "SessionStart",
          additionalContext:
            `Honey mode is ACTIVE (intensity: ${mode}). Apply the "honey" skill ` +
            "reflexively to every response this session: write the minimum code that " +
            "needs to exist (YAGNI; stdlib/native before custom) and say it in the fewest " +
            "clear words — but keep code, commands, identifiers, and safety-critical paths " +
            "(auth, money, migrations, deletes, secrets) exact and uncompressed. If a " +
            "committed memory file (PROJECT.md, or a CLAUDE.md memory section) records a " +
            "fact a change invalidates, update it in the same change. Do not " +
            "spend reasoning tokens deciding how to comply.",
        },
      };
    
      // One-time: re-warn only when the environment changes (upgrade/downgrade),
      // not on every session start.
      if (warnings.length) {
        const MARK = path.join(DIR, ".honey-warned");
        const key = (cc || "?") + "/node" + nodeMajor;
        let prev = "";
        try { prev = fs.readFileSync(MARK, "utf8"); } catch (e) {}
        if (prev !== key) {
          try { fs.writeFileSync(MARK, key); } catch (e) {}
          out.systemMessage = warnings.join("\n");
        }
      }
    
      process.stdout.write(JSON.stringify(out));
    }
    
    if (require.main === module) main();
    module.exports = { claudeCodeVersion, cmpVersions, ccrInert };
    
  • hooks/honey-session.test.jsGitHub
    Read the script
    "use strict";
    const { test } = require("node:test");
    const assert = require("node:assert");
    const { execFileSync } = require("node:child_process");
    const fs = require("node:fs");
    const os = require("node:os");
    const path = require("node:path");
    
    const { claudeCodeVersion, ccrInert } = require("./honey-session");
    const HOOK = path.join(__dirname, "honey-session.js");
    
    test("claudeCodeVersion reads env in priority order", () => {
      assert.strictEqual(claudeCodeVersion({ CLAUDE_CODE_VERSION: "2.1.206" }), "2.1.206");
      assert.strictEqual(claudeCodeVersion({ AI_AGENT: "claude-code_2-1-205_agent" }), "2.1.205");
      assert.strictEqual(
        claudeCodeVersion({ CLAUDE_CODE_EXECPATH: "/x/claude-code/2.1.205/claude.app/Contents/MacOS/claude" }),
        "2.1.205"
      );
      assert.strictEqual(claudeCodeVersion({}), null);
    });
    
    test("ccrInert covers the #68951 broken range", () => {
      assert.strictEqual(ccrInert("2.1.120"), false); // pre-regression
      assert.strictEqual(ccrInert("2.1.121"), true);
      assert.strictEqual(ccrInert("2.1.206"), true);
      assert.strictEqual(ccrInert(null), false); // unknown build → no warning
    });
    
    function runHook(env) {
      return execFileSync("node", [HOOK], { env: { ...process.env, ...env }, encoding: "utf8" });
    }
    
    test("affected version → systemMessage once, silent on repeat, re-warns on upgrade", () => {
      const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "honey-session-test-"));
      fs.writeFileSync(path.join(tmp, ".honey-active"), "full");
      const env = { CLAUDE_CONFIG_DIR: tmp, CLAUDE_CODE_VERSION: "2.1.206", AI_AGENT: "", CLAUDE_CODE_EXECPATH: "" };
    
      const first = JSON.parse(runHook(env));
      assert.match(first.systemMessage, /68951/);
      assert.match(first.hookSpecificOutput.additionalContext, /Honey mode is ACTIVE/);
    
      const second = JSON.parse(runHook(env));
      assert.strictEqual(second.systemMessage, undefined);
    
      const upgraded = JSON.parse(runHook({ ...env, CLAUDE_CODE_VERSION: "2.1.210" }));
      assert.match(upgraded.systemMessage, /68951/);
    });
    
    test("unaffected version → no systemMessage", () => {
      const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "honey-session-test-"));
      fs.writeFileSync(path.join(tmp, ".honey-active"), "full");
      const out = JSON.parse(runHook({
        CLAUDE_CONFIG_DIR: tmp, CLAUDE_CODE_VERSION: "2.1.120", AI_AGENT: "", CLAUDE_CODE_EXECPATH: "",
      }));
      assert.strictEqual(out.systemMessage, undefined);
    });
    
  • hooks/honey-state.jsGitHub
    Read the script
    #!/usr/bin/env node
    // Read/write the Honey active-flag at $CLAUDE_CONFIG_DIR/.honey-active.
    // Usage: honey-state.js get | set <lite|full|ultra|off> | off
    "use strict";
    
    const fs = require("fs");
    const os = require("os");
    const path = require("path");
    
    const DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
    const FLAG = path.join(DIR, ".honey-active");
    const MODES = ["lite", "full", "ultra"];
    
    function read() {
      try {
        return fs.readFileSync(FLAG, "utf8").trim() || null;
      } catch {
        return null;
      }
    }
    function write(mode) {
      fs.mkdirSync(DIR, { recursive: true });
      fs.writeFileSync(FLAG, mode + "\n");
    }
    function clear() {
      try {
        fs.unlinkSync(FLAG);
      } catch {}
    }
    
    const [cmd, arg] = process.argv.slice(2);
    
    if (cmd === "get") {
      process.stdout.write(read() || "off");
    } else if (cmd === "off") {
      clear();
      process.stdout.write("off");
    } else if (cmd === "set") {
      const m = (arg || "full").toLowerCase();
      if (m === "off") {
        clear();
        process.stdout.write("off");
      } else {
        const mode = MODES.includes(m) ? m : "full";
        write(mode);
        process.stdout.write(mode);
      }
    } else {
      process.stderr.write(
        "usage: honey-state.js get | set <lite|full|ultra|off> | off\n"
      );
      process.exit(1);
    }
    
  • hooks/honey-subagent.jsRunsGitHub
    Read the script
    #!/usr/bin/env node
    // SubagentStart hook: dispatched subagents run in isolated context and never
    // inherit the session's Honey directive — they'd emit full-fat code and verbose
    // reports, multiplied per dispatch. If Honey is active, inject the levers into
    // every subagent at spawn. Reviewers get a prose-only variant: compress the
    // report, never the verdict.
    "use strict";
    
    const fs = require("fs");
    const os = require("os");
    const path = require("path");
    
    const WORKER =
      "Apply Honey: write the minimum code that needs to exist — YAGNI, stdlib/native " +
      "before custom; no speculative params, branches, or single-caller abstractions. " +
      "Never cut validation, error handling, auth, or anything the task asked for. " +
      "Keep code, identifiers, paths, and the brief's exact spec values verbatim. " +
      "Report terse: status, one-line test summary, concerns. No narration.";
    
    const REVIEWER =
      "Report findings tersely: id · severity · file:line · one-line fix. Don't narrate " +
      "or restate the diff. Honey governs your prose only — never your verdict or " +
      "severity. Flag everything you normally would; do not suppress or downgrade a " +
      "finding to save words.";
    
    function directiveFor(agentType) {
      return /review|audit|critic|judge/i.test(agentType || "") ? REVIEWER : WORKER;
    }
    
    function activeMode() {
      const dir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
      try {
        const mode = fs.readFileSync(path.join(dir, ".honey-active"), "utf8").trim();
        return mode && mode !== "off" ? mode : null;
      } catch {
        return null;
      }
    }
    
    module.exports = { WORKER, REVIEWER, directiveFor, activeMode };
    
    if (require.main === module) {
      if (!activeMode()) process.exit(0);
      let input = "";
      process.stdin.on("data", (d) => (input += d));
      process.stdin.on("end", () => {
        let agentType = "";
        try {
          agentType = JSON.parse(input).agent_type || "";
        } catch {}
        process.stdout.write(
          JSON.stringify({
            hookSpecificOutput: {
              hookEventName: "SubagentStart",
              additionalContext: directiveFor(agentType),
            },
          })
        );
      });
    }
    
  • hooks/honey-subagent.test.jsGitHub
  • hooks/hooks-json.test.jsGitHub
  • hooks/logcompress-hook-impl.jsGitHub
  • hooks/logcompress-hook.jsRunsGitHub
    Read the script
    #!/usr/bin/env node
    "use strict";
    // ES5 on purpose — this entry file must PARSE on any old system node (desktop-app
    // sessions inherit the launchd PATH, where /usr/local/bin/node can be ancient).
    // The real hook (logcompress-hook-impl.js) uses `??` etc. and needs Node >= 14;
    // requiring it from here keeps old node from ever parsing it. On old node: warn
    // once per node version (stderr + exit 1 so Claude Code surfaces it), then stay
    // silent and fail open. No template literals, arrows, `??`, `?.`, or bare catch here.
    var major = parseInt(process.versions.node, 10);
    if (major >= 14) {
      require("./logcompress-hook-impl.js");
    } else {
      var fs = require("fs");
      var os = require("os");
      var path = require("path");
      var marker = path.join(os.tmpdir(), "honey-node-guard-" + process.versions.node);
      var warned = false;
      try { warned = fs.existsSync(marker); } catch (e) {}
      if (warned) process.exit(0); // fail open, already warned
      try { fs.writeFileSync(marker, ""); } catch (e) {}
      process.stderr.write(
        "honey: the Bash log compressor needs Node >= 14, but hooks run with node " +
        process.versions.node + " (" + process.execPath + "). Bash output will NOT be " +
        "compressed. Fix the node on your system PATH (desktop-app sessions use the " +
        "launchd PATH, not your shell profile).\n"
      );
      process.exit(1); // non-blocking: original tool result is kept, stderr shown to user
    }
    
  • hooks/logcompress-hook.test.jsGitHub
  • hooks/logcompress.jsGitHub
  • hooks/logcompress.test.jsGitHub
  • hooks/precompress-cli.jsGitHub
  • hooks/precompress.jsGitHub
  • hooks/precompress.test.jsGitHub
  • hooks/statusline.jsGitHub

All 16 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.

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.

Ships withhoney

Write less code and say less about it. Honey (I Shrunk the AI) by GreenPT is a cross-tool coding skill that cuts AI coding-agent token usage and LLM API costs — making agents emit less code and less prose without losing correctness.

Get the whole plugin