Skip to content
Productivity
Hook

Hooks

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

From plugin
foreman
355 skills5 hooks

Claude Code

$ npx -y skills add V-Songbird/foreman --agent claude-code

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.

  • Matches^(startup|clear)$node

TaskCreated

  • node

TaskCompleted

  • node

PreToolUse

  • Matches^(Edit|Write)$node

PostToolUse

  • Matches^(Bash|PowerShell)$nodenodenode
Read hooks/hooks.json

Where it lives

  • hooks/antigravity-hook.jsGitHub
    Read the script
    #!/usr/bin/env node
    "use strict";
    
    // Antigravity entrypoint: the one file that knows that host's wire. It reads
    // the event, rewrites the call into the shape hooks/lib.js already reads —
    // tool_name and tool_input with file_path or command, cwd, session_id — and
    // runs the same hook scripts Claude Code and Codex run, as child processes
    // with FOREMAN_HOST pinned, so none of them learns a third payload. What
    // comes back is reshaped for Antigravity: a PreToolUse denial becomes
    // {decision: "deny", reason} and an allow is said out loud. Context a hook
    // adds after a tool call has no channel here — this host's PostToolUse answer
    // is always {} — so it waits in a per-conversation queue under the temp
    // directory and rides the next PreInvocation as a transient message, after
    // the session notice the first invocation of a conversation carries.
    // Antigravity has no task, stop or context-fill event, so those hooks are not
    // registered for it.
    
    const fs = require("fs");
    const os = require("os");
    const path = require("path");
    const { spawnSync } = require("child_process");
    const { readInput } = require("./lib");
    const { configPath, readConfigFile, delegatedOrchestrator } = require("../scripts/foreman-config");
    
    const TOOLS = {
      run_command: (args) => ({ tool_name: "Bash", tool_input: { command: args.CommandLine || "" } }),
      write_to_file: (args) => ({ tool_name: "Write", tool_input: { file_path: args.TargetFile } }),
      replace_file_content: (args) => ({ tool_name: "Edit", tool_input: { file_path: args.TargetFile } }),
      multi_replace_file_content: (args) => ({ tool_name: "Edit", tool_input: { file_path: args.TargetFile } }),
    };
    
    // Inherited host markers would make lib.js read another session's project or
    // answer as another host. The first workspace is the project unless the user
    // named one with FOREMAN_PROJECT_DIR.
    const INHERITED = new Set(["CLAUDE_PROJECT_DIR", "CODEX_CWD", "CODEX_THREAD_ID", "CODEX_SESSION_ID", "PLUGIN_ROOT", "CLAUDE_PLUGIN_ROOT"]);
    
    function translate(data) {
      const call = data.toolCall && typeof data.toolCall === "object" ? data.toolCall : null;
      const args = call && call.args && typeof call.args === "object" ? call.args : {};
      const absolute = (value) => (typeof value === "string" && path.isAbsolute(value) ? value : null);
      const workspace = Array.isArray(data.workspacePaths) ? absolute(data.workspacePaths[0]) : null;
      const target = absolute(args.TargetFile);
      const translated = call ? (TOOLS[call.name] || ((a) => ({ tool_name: call.name, tool_input: a })))(args) : {};
      return {
        workspace,
        payload: {
          session_id: data.conversationId,
          cwd: absolute(args.Cwd) || workspace || (target ? path.dirname(target) : undefined),
          ...translated,
        },
      };
    }
    
    // [Foreman: 470] Each child hook gets 4 s, so a stalled one never holds up
    // Antigravity. The test suite raises that budget through
    // FOREMAN_HOOK_TIMEOUT_MS, so a loaded machine slows a test instead of
    // failing it; the variable can only raise the budget, never lower it.
    const HOOK_TIMEOUT_MS = Math.max(4000, Number.parseInt(process.env.FOREMAN_HOOK_TIMEOUT_MS, 10) || 0);
    
    function runHook(script, payload, workspace) {
      const env = {};
      for (const [key, value] of Object.entries(process.env)) if (!INHERITED.has(key)) env[key] = value;
      env.FOREMAN_HOST = "antigravity";
      if (!env.FOREMAN_PROJECT_DIR && workspace) env.FOREMAN_PROJECT_DIR = workspace;
      const result = spawnSync(process.execPath, [path.join(__dirname, script)], {
        input: JSON.stringify(payload),
        encoding: "utf-8",
        env,
        timeout: HOOK_TIMEOUT_MS,
        windowsHide: true,
      });
      return result.status === 0 && typeof result.stdout === "string" ? result.stdout : "";
    }
    
    function hookOutput(script, payload, workspace) {
      try {
        return JSON.parse(runHook(script, payload, workspace)).hookSpecificOutput || null;
      } catch {
        return null;
      }
    }
    
    // The queue and the session-notice and delegation latches, one file per
    // conversation.
    function statePath(conversationId) {
      const safe = String(conversationId).replace(/[^a-zA-Z0-9-]/g, "_").slice(0, 80);
      return path.join(os.tmpdir(), `foreman-antigravity-${safe}.json`);
    }
    
    function readState(conversationId) {
      try {
        const parsed = JSON.parse(fs.readFileSync(statePath(conversationId), "utf-8"));
        if (parsed && typeof parsed === "object") {
          return {
            started: parsed.started === true,
            pending: Array.isArray(parsed.pending) ? parsed.pending : [],
            ...(typeof parsed.delegated === "string" ? { delegated: parsed.delegated } : {}),
            ...(typeof parsed.config === "string" || parsed.config === null ? { config: parsed.config } : {}),
          };
        }
      } catch {
        // missing or corrupt: a conversation nothing has been said in yet
      }
      return { started: false, pending: [] };
    }
    
    function writeState(conversationId, state) {
      try {
        fs.writeFileSync(statePath(conversationId), JSON.stringify(state));
      } catch {
        // best effort: a lost queue costs one notice, never the tool call
      }
    }
    
    const DAY_MS = 24 * 60 * 60 * 1000;
    
    // Queues outlive their conversations. A conversation that is new, or back
    // after a day of silence, deletes every queue idle for more than a day. The
    // scan reads the whole temp directory, so a live conversation, which rewrites
    // its own file on every call, skips it. Its own file is never deleted: it
    // latches the notice. A file that will not go waits for the next sweep.
    function pruneQueues(conversationId) {
      const own = statePath(conversationId);
      const dir = path.dirname(own);
      const idle = (file) => Date.now() - fs.statSync(file).mtimeMs > DAY_MS;
      try {
        if (fs.existsSync(own) && !idle(own)) return;
        for (const name of fs.readdirSync(dir)) {
          if (!/^foreman-antigravity-[\w-]+\.json$/.test(name) || name === path.basename(own)) continue;
          const file = path.join(dir, name);
          try {
            if (idle(file)) fs.unlinkSync(file);
          } catch {
            // gone mid-scan, or 
  • hooks/codex-spawn.jsGitHub
    Read the script
    #!/usr/bin/env node
    "use strict";
    
    // Codex only (hooks/codex-hooks.json). [Foreman: 926] A subagent that carries
    // an entry's handoff is named `foreman_<id>`, so its finish can be tied back
    // to that entry. Codex shows the name only at the spawn: PreToolUse on
    // `collaborationspawn_agent` sees `tool_input.task_name`, but SubagentStart and
    // SubagentStop carry only `agent_id` and the subagent's rollout path. The
    // rollout's first record (`session_meta`) names the subagent's `agent_path`,
    // which ends in its task_name. That record is an internal Codex format, not a
    // documented hook field: when it is unreadable or changes shape, nothing maps
    // and the close check falls back to explicit `codex-task.js check` calls.
    
    const fs = require("fs");
    const path = require("path");
    const { readInput, projectDir } = require("./lib");
    const { readEntries, ID_PATTERN } = require("../scripts/roadmap");
    
    const SPAWN_TOOL = "collaborationspawn_agent";
    const TASK_NAME_RE = new RegExp(`^foreman_(${ID_PATTERN})$`);
    // The first record also holds the base instructions, so it can run to tens of
    // kilobytes; a first line longer than this is not a record this reads.
    const MAX_META_BYTES = 4 * 1024 * 1024;
    
    // PreToolUse: a task_name that is "foreman" or starts with "foreman_" must be
    // exactly foreman_<id> for an entry in this roadmap. Codex 0.157.1 hands the hook the
    // spawn message encrypted, so a handoff cannot be told from a helper: every
    // other name passes, and the handoff wording carries the naming rule.
    function main(data = readInput()) {
      if (data.tool_name !== SPAWN_TOOL) return;
      const name = String((data.tool_input || {}).task_name || "");
      if (name !== "foreman" && !name.startsWith("foreman_")) return;
      const root = projectDir(data);
      // A project that never ran init has no entries to reserve names for.
      if (!fs.existsSync(path.join(root, "ROADMAP.jsonl"))) return;
      const match = TASK_NAME_RE.exec(name);
      let entries;
      try { entries = readEntries(root); } catch { return; }
      if (match && entries.some((e) => e.id === match[1])) return;
      process.stdout.write(JSON.stringify({
        hookSpecificOutput: {
          hookEventName: "PreToolUse",
          permissionDecision: "deny",
          permissionDecisionReason: `Foreman: task_name "${name}" is reserved for roadmap handoffs and must be exactly foreman_<id>, naming an entry in ROADMAP.jsonl. Spawn again with the handed-off entry's name, or with a name that does not start with "foreman_".`,
        },
      }));
    }
    
    function firstRecord(file) {
      const fd = fs.openSync(file, "r");
      try {
        const chunks = [];
        const buffer = Buffer.alloc(64 * 1024);
        for (let read = 0, n; read < MAX_META_BYTES && (n = fs.readSync(fd, buffer, 0, buffer.length, read)) > 0; read += n) {
          const end = buffer.subarray(0, n).indexOf(10);
          chunks.push(Buffer.from(buffer.subarray(0, end < 0 ? n : end)));
          if (end >= 0) return JSON.parse(Buffer.concat(chunks).toString("utf-8"));
        }
        return null;
      } finally {
        fs.closeSync(fd);
      }
    }
    
    // SubagentStop: the entry a foreman_<id> subagent worked on, and the scope of
    // the thread that spawned it: "" for the main thread, whose id is the
    // session's, or the spawning subagent's id, which its shell sees as
    // CODEX_THREAD_ID (codex-task.js currentScope).
    function subagentEntry(data) {
      if (!data.agent_transcript_path) return null;
      let record;
      try { record = firstRecord(data.agent_transcript_path); } catch { return null; }
      const meta = record && record.type === "session_meta" ? record.payload || {} : {};
      const match = TASK_NAME_RE.exec(String(meta.agent_path || "").split("/").pop());
      if (!match) return null;
      const parent = String(meta.parent_thread_id || "");
      return { id: match[1], agent: parent && parent !== data.session_id ? parent : "" };
    }
    
    if (require.main === module) { try { main(); } catch { /* hooks fail open */ } }
    module.exports = { main, subagentEntry };
    
  • hooks/codex-task.jsGitHub
    Read the script
    #!/usr/bin/env node
    "use strict";
    
    // Codex has no TaskCreated/TaskCompleted events. Explicit start/check calls
    // preserve those checkpoints without treating update_plan or every Stop as
    // a task completion. Stop only considers an attempted check in this scope.
    const fs = require("fs");
    const os = require("os");
    const path = require("path");
    const crypto = require("crypto");
    const { projectDir, hostName, pluginDir } = require("./lib");
    const { readEntries, cmdUpdateStatus, isValidId, soleHolder } = require("../scripts/roadmap");
    const { recordResumeRecovered } = require("../scripts/trial-log");
    const { discoveryEnabled, discoveryInstructions, delegatedDiscoveryInstructions } = require("../scripts/discovery");
    const { readConfigFile, delegatedOrchestrator } = require("../scripts/foreman-config");
    const { detectHost, sessionId, parseFlags, printHelp } = require("../scripts/runtime");
    const OPEN = new Set(["planned", "in_progress"]);
    
    function scopePath(root, session, agent = "") {
      if (!session) return null;
      const hash = crypto.createHash("sha256").update(JSON.stringify([path.resolve(root), session, agent])).digest("hex");
      return path.join(os.tmpdir(), "foreman-codex-checkpoints", hash);
    }
    
    function currentScope(options = {}, env = process.env) {
      // [Foreman: 850] Antigravity's run_command environment carries the
      // conversation's id, the one its hook payloads name. [Foreman: 880] Only
      // there: a Codex or Claude Code process inherits it from an Antigravity
      // terminal, and a scope armed under it is one no Stop of theirs reads.
      // Claude Code closes through its own task hooks, so it names no session here.
      const host = detectHost(env);
      const session = options.session ?? (host === "claude" ? "" : sessionId(env, host));
      const thread = env.CODEX_THREAD_ID || "";
      const agent = options.agent ?? (options.session === undefined && env.CODEX_SESSION_ID && thread !== session ? thread : "");
      return { session, agent };
    }
    
    function checkpoint(action, options) {
      const root = path.resolve(options.root || projectDir({}));
      if (!isValidId(options.id)) throw new Error("--id must be a roadmap entry id");
      // [Foreman: 760] A duplicated id is refused, not read from its first holder.
      const holderOf = () => soleHolder(readEntries(root), options.id, "ROADMAP.jsonl");
      let entry = holderOf();
      if (!entry) throw new Error(`no entry with id ${options.id}`);
      let transition;
      if (action === "start" && entry.status === "planned") {
        transition = cmdUpdateStatus(root, { id: entry.id, status: "in_progress", expected_status: "planned", require_ready: true });
        entry = holderOf();
      }
      const dispatchReady = action === "start" && entry.status === "in_progress";
      if (action === "start" && !dispatchReady) {
        return { id: entry.id, status: entry.status, complete: !OPEN.has(entry.status), dispatchReady: false, skipped: true, reason: transition?.reason || `entry is ${entry.status}, not in_progress`, transition, stop_gate_scoped: false };
      }
      const complete = !OPEN.has(entry.status);
      const { session, agent } = currentScope(options);
      // Antigravity has no stop event to read the file, so its checks arm nothing.
      const scope = hostName() === "antigravity" ? null : scopePath(root, session, agent);
      // A filename per entry keeps concurrent checkpoints from losing each other.
      // Only explicit check attempts arm Stop; asking the user a question after
      // start must never become an implicit demand to finish the task.
      if (scope) {
        const file = path.join(scope, `${entry.id}.json`);
        if (action === "check" && !complete) {
          fs.mkdirSync(scope, { recursive: true });
          fs.writeFileSync(file, JSON.stringify({ id: entry.id }), "utf-8");
        } else {
          try { fs.unlinkSync(file); } catch (err) { if (err.code !== "ENOENT") throw err; }
        }
      }
      if (action === "check" && complete && ((entry.commits || []).length || (entry.observed_touches || []).length)) {
        recordResumeRecovered({ root });
      }
      // [Foreman: 828] A session delegatedAcceptance lists hears who accepts for
      // it, and gets discovery wording that reports to that orchestrator instead
      // of asking the user. Antigravity's session comes from --session or
      // ANTIGRAVITY_CONVERSATION_ID (850).
      // A background subagent keeps the shared policy: it returns its candidates
      // to its coordinator, which is the listed session.
      const delegatedTo = agent ? null : delegatedOrchestrator(readConfigFile(root).config, session);
      const discovery = !discoveryEnabled(root) ? undefined
        : delegatedTo ? delegatedDiscoveryInstructions(delegatedTo, hostName(), path.join(pluginDir(), "scripts", "roadmap.js"))
        : discoveryInstructions();
      return { id: entry.id, status: entry.status, complete, ...(action === "start" ? { dispatchReady, transition } : {}), stop_gate_scoped: Boolean(scope), ...(delegatedTo ? { delegatedTo } : {}), ...(discovery ? { discovery } : {}) };
    }
    
    const USAGE = "usage: codex-task.js start|check --id ID [--root PATH] [--session ID] [--agent ID]";
    
    function main(argv = process.argv.slice(2)) {
      if (printHelp(argv, USAGE)) return;
      const [action, ...args] = argv;
      if (!["start", "check"].includes(action)) throw new Error(USAGE);
      // [Foreman: 771] The same flag parser as the scripts/ CLIs: a repeated
      // --id fails instead of starting the last one given.
      const options = parseFlags(`codex-task.js ${action}`, { id: "value", root: "value", session: "value", agent: "value" }, args);
      const result = checkpoint(action, options);
      process.stdout.write(JSON.stringify(result));
      if ((action === "check" && !result.complete) || (action === "start" && !result.dispatchReady)) process.exitCode = 1;
    }
    
    if (require.main === module) {
      try { main(); } catch (error) {
        process.stdout.write(JSON.stringify({ ok: false, error: error.message }));
        process.exitCode = 1;
      }
    }
    module.exports = { main, checkpoint, scopePath, currentScope, OPEN };
    
  • hooks/context-fill.jsGitHub
    Read the script
    "use strict";
    
    // context-fill.js — PostToolUse hook on Bash/PowerShell, registered for
    // Claude Code only (hooks/hooks.json). Codex has no stable context-fill input
    // and its transcript is a different format that must never be read as Claude
    // usage, so hooks/codex-hooks.json does not register this file and main()
    // stays silent if a Codex payload reaches it anyway.
    //
    // The destination question ("How do you want to run this?") recommends one
    // option, and one of the facts that should move that recommendation is how
    // much room this session has left: a nearly-full session is the worst place
    // to execute a fresh task in. Nothing in a hook payload carries that number
    // — `context_window.used_percentage` exists only in the statusline JSON,
    // which a skill cannot invoke — so this hook derives it the one other way
    // available: the transcript's own last assistant `usage` block, summed.
    //
    // It speaks only when the session is at or above CONTEXT_SHARE of a
    // window the user actually configured, and only after one of the scripts a
    // crafting flow runs *before* that question. Below the line, or with no
    // configured window to measure against, it writes nothing and costs
    // nothing, which is the common case. The number is a fact for the flow to
    // apply; the rule that acts on it lives in skills/roadmap/destination-question.md, so the
    // wording of the recommendation stays in one place.
    //
    // Every failure path here is silence. A missing transcript, an unreadable
    // one, a format change in the .jsonl (officially unstable) — none of them
    // may break a pick, so the whole read is best-effort and the flow simply
    // falls back to its default recommendation.
    
    const fs = require("fs");
    const os = require("os");
    const path = require("path");
    
    const { readInput, hostName } = require("./lib");
    
    const WATCHED_TOOLS = new Set(["Bash", "PowerShell"]);
    
    // The scripts a crafting flow runs before it asks the destination question:
    // `roadmap.js` (pick's `step pick` calls, which report the selected entry
    // before Q2), `resolve-symbols.js`
    // (craft-prompt's pre-question read), and `safe-commit.js` (the tree probe the
    // destination question itself takes before offering the split). Anything else
    // — including `craft-handoff.js`, which runs *after* the answer — is past the
    // moment this number could change and is not worth a line.
    const PRE_QUESTION_SCRIPT = /scripts[\\/](roadmap|resolve-symbols|safe-commit)\.js/;
    
    // The share of the window at which executing in this session stops being the
    // obvious default. Deliberately not configurable: the window size underneath
    // it is already an assumption, so a second knob would only add precision this
    // number does not have.
    const CONTEXT_SHARE = 0.25;
    
    // The range `/autocompact` accepts, and so the only values worth believing.
    const WINDOW_MIN = 100000;
    const WINDOW_MAX = 1000000;
    
    // Read far enough back to clear the largest single tool result a turn can
    // hold, so the newest assistant record is inside the slice.
    const TAIL_BYTES = 1024 * 1024;
    
    function windowValue(raw) {
      const n = Number(raw);
      return Number.isFinite(n) && n >= WINDOW_MIN && n <= WINDOW_MAX ? n : null;
    }
    
    // How full the window may get before the host compacts on its own. The env
    // var wins over the setting `/autocompact` saves, matching the host's own
    // precedence. Null when the user set neither — no hook input carries the real
    // window size, so there is nothing else to read.
    function autoCompactWindow() {
      const fromEnv = windowValue(process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW);
      if (fromEnv) return fromEnv;
      const dir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
      try {
        const settings = JSON.parse(fs.readFileSync(path.join(dir, "settings.json"), "utf-8"));
        return windowValue(settings.autoCompactWindow);
      } catch {
        return null; // absent, unreadable, or not JSON — treat as unset
      }
    }
    
    // Context occupancy ≈ the most recent main-thread request: the last
    // non-sidechain assistant `usage`, summed across every numeric `*_tokens`
    // field (uncached input + cache read + cache write + output). A sidechain
    // record belongs to a subagent's own window, not this one. Null when the
    // transcript holds no usable record.
    function currentTokens(transcriptPath) {
      if (!transcriptPath || typeof transcriptPath !== "string") return null;
    
      let lines;
      try {
        const fd = fs.openSync(transcriptPath, "r");
        try {
          const size = fs.fstatSync(fd).size;
          const start = Math.max(0, size - TAIL_BYTES);
          const buf = Buffer.alloc(size - start);
          fs.readSync(fd, buf, 0, buf.length, start);
          lines = buf.toString("utf-8").split("\n");
          if (start > 0) lines = lines.slice(1); // drop the partial first line
        } finally {
          fs.closeSync(fd);
        }
      } catch {
        return null;
      }
    
      for (let i = lines.length - 1; i >= 0; i--) {
        const line = lines[i];
        if (!line) continue;
        let entry;
        try {
          entry = JSON.parse(line);
        } catch {
          continue; // torn or partial line
        }
        if (!entry || entry.type !== "assistant" || entry.isSidechain) continue;
        const usage = entry.message && entry.message.usage;
        if (!usage || typeof usage !== "object") continue;
        let sum = 0;
        for (const key of Object.keys(usage)) {
          if (/_tokens$/.test(key) && typeof usage[key] === "number") sum += usage[key];
        }
        return sum;
      }
      return null;
    }
    
    // The one decision: is this session full enough that the destination question
    // should hear about it? Returns null for silence, or the numbers the message
    // is built from. An unconfigured window is silence: window sizes range from
    // 100k to 1M, so any stand-in number is wrong by up to 5x in one direction or
    // the other, and a wrong reading recommends a fresh session to someone who has
    // most of their window left. Silence just leaves the default recommendation
    // standing, which is the right answer when the share is unknowable.
    function assess(tokens, confi
  • hooks/guard-roadmap-edit.jsGitHub
    Read the script
    #!/usr/bin/env node
    "use strict";
    
    const fs = require("fs");
    const path = require("path");
    const { readInput, projectDir, pluginDir, touchedPaths } = require("./lib");
    
    const PLUGIN_ROOT = pluginDir();
    const SCRIPT_PATH = path.join(PLUGIN_ROOT, "scripts", "roadmap.js");
    
    const WATCHED_TOOLS = new Set(["apply_patch", "Edit", "Write"]);
    
    // ROADMAP.jsonl is a basename-only match, deliberately not path-aware — a
    // project having some unrelated file literally named ROADMAP.jsonl elsewhere
    // isn't worth distinguishing from the real one at this scale.
    // [Foreman: 132] archive.jsonl is the same file in a later life: the archived
    // half of the roadmap, written by the same CLI (archive/restore), so a hand
    // edit bypasses the same invariants. Unlike the roadmap, though, the name is
    // generic — so only this project's own copy counts, and some other tool's
    // archive.jsonl is not Foreman's to deny.
    const PROJECT_ARCHIVE = ".foreman/archive.jsonl";
    
    function projectRelative(filePath, root) {
      return path
        .relative(root, path.resolve(root, String(filePath)))
        .replaceAll("\\", "/")
        .toLowerCase();
    }
    
    /** The project-relative path this edit targets, or null when it targets none. */
    function guardedPath(filePath, root) {
      if (!filePath) return null;
      const base = path.basename(String(filePath)).toLowerCase();
      if (base === "roadmap.jsonl") return "ROADMAP.jsonl";
      if (base !== "archive.jsonl") return null;
      return projectRelative(filePath, root) === PROJECT_ARCHIVE ? PROJECT_ARCHIVE : null;
    }
    
    function main(data = readInput()) {
      if (!WATCHED_TOOLS.has(data.tool_name)) return;
    
      const root = projectDir(data);
      // A project that never ran init has no roadmap to guard, and nothing
      // Foreman is entitled to say about it.
      if (!fs.existsSync(path.join(root, "ROADMAP.jsonl"))) return;
    
      const filePath = touchedPaths(data).find((file) => guardedPath(file, root) !== null);
      if (!filePath) return;
    
      // [Foreman: 734] The verbs come from the dispatcher's own table, so a new
      // subcommand is named here without a second copy. Required only on a deny:
      // every other Edit and Write skips loading the CLI.
      const { SUBCOMMAND_FLAGS } = require("../scripts/roadmap");
      const payload = {
        hookSpecificOutput: {
          hookEventName: "PreToolUse",
          permissionDecision: "deny",
          permissionDecisionReason:
            `Foreman: direct ${data.tool_name} of ` +
            `${path.basename(filePath)} is blocked. Use ` +
            `node "${SCRIPT_PATH}" instead (${Object.keys(SUBCOMMAND_FLAGS).join("/")} ` +
            "— run `<subcommand> --help` for one subcommand's usage). " +
            "It enforces id computation and parse-before/after-write; a hand " +
            "edit bypasses both. If the file is corrupt and the CLI itself " +
            "can't read it, inspect the corruption and use an explicitly scoped shell repair. " +
            "This hook covers apply_patch and file edit tools; it does not parse arbitrary shell writes.",
        },
      };
      try {
        process.stdout.write(Buffer.from(JSON.stringify(payload), "utf-8"));
      } catch {
        // ignore
      }
    }
    
    if (require.main === module) {
      try {
        main();
      } catch {
        process.exit(0);
      }
    }
    
    module.exports = { main, guardedPath };
    
  • hooks/lib.jsGitHub
    Read the script
    "use strict";
    
    // The two helpers every Foreman hook shares. Hook stdin is the harness's
    // one delivery channel and a hook must never die on bad input, so read
    // failures and non-object payloads collapse to {}; projectDir resolves the same way in every
    // hook so all of them agree on which project a payload belongs to. The
    // tmpdir latches deliberately stay local to each hook — their key
    // semantics differ per hook, so there is nothing shared to extract there.
    
    const fs = require("fs");
    const path = require("path");
    const { detectHost } = require("../scripts/runtime");
    
    function readInput() {
      let raw;
      try {
        raw = fs.readFileSync(0, "utf-8");
      } catch {
        return {};
      }
      try {
        // A payload that is not a plain object (null, a number, a string, an
        // array) takes the same empty-payload path as unreadable stdin.
        const data = JSON.parse(raw || "{}");
        return data && typeof data === "object" && !Array.isArray(data) ? data : {};
      } catch {
        return {};
      }
    }
    
    function hostName(env = process.env) {
      return detectHost(env);
    }
    
    function projectDir(data, env = process.env) {
      // An explicit Foreman target wins. Claude Code exports the session's project
      // root to every hook, while the payload cwd can be a subdirectory the session
      // moved into, so that root comes first there. Codex has no such variable:
      // its payload identifies the session, and a CLAUDE_PROJECT_DIR it inherited
      // belongs to some other session, so it is only the last resort.
      const ordered = hostName(env) === "codex"
        ? [env.FOREMAN_PROJECT_DIR, data?.cwd, env.CODEX_CWD, env.CLAUDE_PROJECT_DIR]
        : [env.FOREMAN_PROJECT_DIR, env.CLAUDE_PROJECT_DIR, data?.cwd];
      return path.resolve(ordered.find(Boolean) || process.cwd());
    }
    
    function pluginDir() {
      return path.resolve(process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || path.join(__dirname, ".."));
    }
    
    // Codex reports apply_patch as one call containing an entire patch, including
    // rename destinations. Only structural headers count; added/context text may
    // itself contain strings that resemble paths or patch headers.
    function patchPaths(command) {
      if (typeof command !== "string") return [];
      const lines = command.trim().split(/\r?\n/);
      if (lines[0] !== "*** Begin Patch" || lines.at(-1) !== "*** End Patch") return [];
      const paths = [];
      for (const line of lines.slice(1, -1)) {
        const m = /^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/.exec(line);
        if (m) paths.push(m[1]);
      }
      return [...new Set(paths)];
    }
    
    function touchedPaths(data) {
      if (data?.tool_name === "apply_patch") return patchPaths(data.tool_input?.command);
      if (["Read", "Edit", "Write"].includes(data?.tool_name) && typeof data.tool_input?.file_path === "string") {
        return [data.tool_input.file_path];
      }
      return [];
    }
    
    module.exports = { readInput, hostName, projectDir, pluginDir, patchPaths, touchedPaths };
    
  • hooks/post-commit.jsGitHub
  • hooks/session-start.jsGitHub
  • hooks/stop.jsGitHub
  • hooks/task-completed.jsGitHub
  • hooks/task-created.jsGitHub
  • hooks/windows-launcher.ps1GitHub

All 12 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.

@v-songbird
Ships withforeman

Project continuity for Claude Code and Codex: a roadmap beside your code, grounded handoffs, and clear task status.

Stars on GitHub

Momentum

stars in total
35
Star history of V-Songbird/foreman 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 foreman.

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