Skip to content
Development
Hook

Hooks

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

From plugin
lucasduys-forge
5610 skills9 agents13 commands3 hooks
Install
> /plugin marketplace add LucasDuys/forge
> /plugin install forge@forge-marketplace

Ships with lucasduys-forge. Installing the plugin gets these hooks.

What fires, and when

Stop

  • ${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh

PreToolUse

  • MatchesBash|Grep|Glob|Readnode ${CLAUDE_PLUGIN_ROOT}/hooks/tool-cache.js

PostToolUse

  • ${CLAUDE_PLUGIN_ROOT}/hooks/token-monitor.sh
  • MatchesBashnode ${CLAUDE_PLUGIN_ROOT}/hooks/test-output-filter.js
  • MatchesBashnode ${CLAUDE_PLUGIN_ROOT}/hooks/output-filter.js
  • MatchesBashnode ${CLAUDE_PLUGIN_ROOT}/hooks/auto-backprop.js
  • MatchesBash|Grep|Glob|Readnode ${CLAUDE_PLUGIN_ROOT}/hooks/tool-cache-store.js
  • node ${CLAUDE_PLUGIN_ROOT}/hooks/progress-tracker.js
Read hooks/hooks.json

In the plugin's words

How lucasduys-forge describes its own hook set.

Forge plugin hooks — autonomous loop engine, token monitoring, progress tracking, test filtering, tool caching, and auto-backprop

Where it lives

  • hooks/auto-backprop.jsRunsGitHub
    Read the script
    #!/usr/bin/env node
    // PostToolUse hook -- detects runtime test failures and queues auto-backprop.
    // Matcher: "Bash"
    //
    // Reads PostToolUse JSON from stdin. If the tool was a Bash invocation
    // running a recognised test runner AND the output contains failure markers,
    // writes a flag file at .forge/.auto-backprop-pending.json with the captured
    // failure context. The Forge state machine (or the user's next /forge resume)
    // picks up the flag and runs /forge backprop automatically before continuing.
    //
    // Opt-out: set auto_backprop:false in .forge/config.json or set the env var
    // FORGE_AUTO_BACKPROP=0. Default is on -- it costs nothing when no failures
    // occur because the hook exits immediately on non-test output.
    //
    // This hook is the *trigger* side of auto-backprop. The *consumer* side
    // lives in stop-hook.sh which prepends a backprop request to the next
    // prompt when the flag file exists.
    
    'use strict';
    
    const fs = require('node:fs');
    const path = require('node:path');
    
    const TIMEOUT_MS = 3000;
    const MAX_CONTEXT_BYTES = 4000;  // truncate failure capture to keep state lean
    
    // Test runner detection -- same family as test-output-filter.js so the two
    // hooks fire on identical command sets.
    const TEST_COMMANDS = [
      /\bvitest\b/,
      /\bjest\b/,
      /\bpytest\b/,
      /\bcargo\s+test\b/,
      /\bgo\s+test\b/,
      /\bnpm\s+(?:run\s+)?test\b/,
      /\bnpx\s+test\b/,
      /\bmocha\b/,
      /\bnode\s+--test\b/,
      /\bnode\s+.*run-tests\.cjs\b/,
    ];
    
    // Failure signal patterns. We require BOTH a runner match AND a failure
    // pattern -- prevents false positives from grep output, build noise, etc.
    const FAILURE_PATTERNS = [
      /\bFAIL\b/,
      /\bFAILED\b/,
      /AssertionError/,
      /Error: expect/,
      /^\s*not ok\s+\d+/m,
      /\d+ failing/,
      /\d+ failed/,
      /Tests:\s+\d+\s+failed/,
    ];
    
    // Patterns that indicate a successful run -- override failure detection so
    // `0 failed` doesn't trip on the literal word "failed".
    const SUCCESS_PATTERNS = [
      /\b0 failing\b/,
      /\b0 failed\b/,
      /Tests:\s+0\s+failed/,
    ];
    
    function isTestCommand(cmd) {
      for (const re of TEST_COMMANDS) if (re.test(cmd)) return true;
      return false;
    }
    
    function looksLikeFailure(output) {
      if (SUCCESS_PATTERNS.some((re) => re.test(output))) return false;
      return FAILURE_PATTERNS.some((re) => re.test(output));
    }
    
    // Find the .forge directory by walking up from CWD. Hooks run with CWD set
    // to the project root by Claude Code, so this should usually find it on the
    // first try.
    function findForgeDir() {
      let dir = process.cwd();
      for (let i = 0; i < 8; i++) {
        const candidate = path.join(dir, '.forge');
        if (fs.existsSync(candidate)) return candidate;
        const parent = path.dirname(dir);
        if (parent === dir) return null;
        dir = parent;
      }
      return null;
    }
    
    // Read .forge/config.json and check the auto_backprop opt-out.
    function isEnabled(forgeDir) {
      if (process.env.FORGE_AUTO_BACKPROP === '0') return false;
      try {
        const cfg = JSON.parse(fs.readFileSync(path.join(forgeDir, 'config.json'), 'utf8'));
        if (cfg.auto_backprop === false) return false;
      } catch (e) { /* config missing -> default on */ }
      return true;
    }
    
    // Capture the most relevant slice of the failure output -- failure lines
    // plus 4 lines of context above and below, capped at MAX_CONTEXT_BYTES.
    function captureFailureContext(output) {
      const lines = output.split('\n');
      const keep = new Set();
      for (let i = 0; i < lines.length; i++) {
        if (looksLikeFailure(lines[i])) {
          for (let j = Math.max(0, i - 4); j <= Math.min(lines.length - 1, i + 8); j++) {
            keep.add(j);
          }
        }
      }
      // Always include the last 5 lines (test summary tail).
      for (let i = Math.max(0, lines.length - 5); i < lines.length; i++) keep.add(i);
      const sorted = Array.from(keep).sort((a, b) => a - b);
      let captured = sorted.map((i) => lines[i]).join('\n');
      if (captured.length > MAX_CONTEXT_BYTES) {
        captured = captured.slice(0, MAX_CONTEXT_BYTES) + '\n...(truncated)';
      }
      return captured;
    }
    
    function writeFlagFile(forgeDir, payload) {
      const flagPath = path.join(forgeDir, '.auto-backprop-pending.json');
      // If a flag already exists, do not overwrite -- the queued failure should
      // be handled before a new one is captured. This also makes the hook
      // idempotent across PostToolUse fires for the same failure.
      if (fs.existsSync(flagPath)) return false;
      try {
        fs.writeFileSync(flagPath, JSON.stringify(payload, null, 2));
        return true;
      } catch (e) {
        return false;
      }
    }
    
    // Also flip the auto_backprop_pending flag in state.md frontmatter so the
    // TUI dashboard's BACKPROP banner lights up. We do a minimal in-place edit
    // without disturbing the rest of the file.
    function setStatePendingFlag(forgeDir) {
      const statePath = path.join(forgeDir, 'state.md');
      try {
        let content = fs.readFileSync(statePath, 'utf8');
        if (/^\s*auto_backprop_pending\s*:/m.test(content)) {
          content = content.replace(/^\s*auto_backprop_pending\s*:.*$/m, 'auto_backprop_pending: true');
        } else {
          // Insert before the closing --- of the frontmatter
          content = content.replace(/^(---\r?\n[\s\S]*?)(\r?\n---)/, '$1\nauto_backprop_pending: true$2');
        }
        fs.writeFileSync(statePath, content);
      } catch (e) { /* state.md missing -> non-fatal, flag file still written */ }
    }
    
    function main() {
      const timer = setTimeout(() => process.exit(0), TIMEOUT_MS);
      if (timer.unref) timer.unref();
    
      const chunks = [];
      process.stdin.setEncoding('utf8');
      process.stdin.on('data', (c) => chunks.push(c));
      process.stdin.on('error', () => process.exit(0));
      process.stdin.on('end', () => {
        clearTimeout(timer);
        try {
          const raw = chunks.join('');
          if (!raw.trim()) return process.exit(0);
    
          const payload = JSON.parse(raw);
          if ((payload.tool_name || '') !== 'Bash') return process.exit(0);
    
          const command = (payload.tool_input && payload.tool_input.command) || '';
          const output = payload.tool_output || '';
    
          if (!isTestCommand(co
  • hooks/output-filter.jsRunsGitHub
    Read the script
    #!/usr/bin/env node
    // PostToolUse hook -- generalized output filter for non-test Bash commands.
    // Matcher: "Bash"
    //
    // Reads PostToolUse JSON from stdin. If the tool was a Bash invocation
    // running a recognised command class (package install, build, git diff,
    // find, curl), and the output exceeds the per-class threshold, replaces
    // the output with a condensed view that preserves errors/warnings/summary.
    // Outputs nothing (exit 0) when not applicable -- zero context cost.
    //
    // Sibling to hooks/test-output-filter.js (which handles test runners).
    // The two are deliberately separate to keep each rule set focused.
    
    'use strict';
    
    // --- thresholds ----------------------------------------------------------
    
    var DEFAULT_THRESHOLD = 2000;
    var CURL_THRESHOLD = 10240;
    var TIMEOUT_MS = 3000;
    
    // Per-class strategy parameters
    var INSTALL_HEAD = 5;
    var INSTALL_TAIL = 3;
    var BUILD_TAIL = 3;
    var DIFF_LINES_PER_FILE = 100;
    var FIND_HEAD = 50;
    var FIND_TAIL = 10;
    var CURL_BODY_BYTES = 1024;
    
    // --- command matchers ----------------------------------------------------
    
    var INSTALL_PATTERNS = [
      /^(npm|yarn|pnpm|bun|pip|pip3)\s+(install|ci|add)\b/,
      /^cargo\s+add\b/
    ];
    
    var BUILD_PATTERNS = [
      /^webpack\b/,
      /^vite\s+build\b/,
      /^tsc(\s|$)/, // tsc, tsc --noEmit, tsc -b
      /^cargo\s+build\b/,
      /^go\s+build\b/,
      /^swc\b/,
      /^esbuild\b/
    ];
    
    var DIFF_PATTERN = /^git\s+diff\b/;
    var FIND_PATTERN = /^find\b/;
    var CURL_PATTERN = /^curl\b/;
    
    // Lines worth keeping in install/build output (warnings + errors).
    var WARN_ERR_PATTERN = /\b(WARN|WARNING|warning|ERR|ERROR|error|err!)\b/;
    
    // --- helpers -------------------------------------------------------------
    
    function trimCommand(cmd) {
      if (typeof cmd !== 'string') return '';
      // Strip leading "$ ", "sudo ", and surrounding whitespace.
      var s = cmd.replace(/^\s+/, '').replace(/^\$\s+/, '').replace(/^sudo\s+/, '');
      return s;
    }
    
    function matchesAny(patterns, cmd) {
      var s = trimCommand(cmd);
      for (var i = 0; i < patterns.length; i++) {
        if (patterns[i].test(s)) return true;
      }
      return false;
    }
    
    function matches(pattern, cmd) {
      return pattern.test(trimCommand(cmd));
    }
    
    // --- filter classes ------------------------------------------------------
    
    // Package install: keep first 5 lines + warn/err lines + last 3 lines.
    function filterPackageInstall(output, cmd) {
      if (typeof output !== 'string') return output;
      if (!matchesAny(INSTALL_PATTERNS, cmd)) return output;
      if (output.length <= DEFAULT_THRESHOLD) return output;
    
      var lines = output.split('\n');
      var n = lines.length;
      var keep = new Array(n);
      for (var i = 0; i < INSTALL_HEAD && i < n; i++) keep[i] = true;
      for (var t = Math.max(0, n - INSTALL_TAIL); t < n; t++) keep[t] = true;
      for (var k = 0; k < n; k++) {
        if (WARN_ERR_PATTERN.test(lines[k])) keep[k] = true;
      }
      return joinKept(lines, keep, 'package-install');
    }
    
    // Build: keep error/warning lines + last 3 lines (final status).
    function filterBuild(output, cmd) {
      if (typeof output !== 'string') return output;
      if (!matchesAny(BUILD_PATTERNS, cmd)) return output;
      if (output.length <= DEFAULT_THRESHOLD) return output;
    
      var lines = output.split('\n');
      var n = lines.length;
      var keep = new Array(n);
      for (var t = Math.max(0, n - BUILD_TAIL); t < n; t++) keep[t] = true;
      for (var k = 0; k < n; k++) {
        if (WARN_ERR_PATTERN.test(lines[k])) keep[k] = true;
      }
      // If nothing matched (no errors, no warnings) we still need head context
      // so the user knows what ran -- keep first line.
      var any = false;
      for (var x = 0; x < n; x++) if (keep[x]) { any = true; break; }
      if (!any && n > 0) keep[0] = true;
      return joinKept(lines, keep, 'build');
    }
    
    // Git diff: per-file keep header + first 100 lines, replace omitted
    // per-file body with a single truncation marker line.
    function filterGitDiff(output, cmd) {
      if (typeof output !== 'string') return output;
      if (!matches(DIFF_PATTERN, cmd)) return output;
      if (output.length <= DEFAULT_THRESHOLD) return output;
    
      var lines = output.split('\n');
      // Split into per-file blocks separated by "diff --git" headers.
      var out = [];
      var block = [];
      function flushBlock() {
        if (block.length === 0) return;
        if (block.length <= DIFF_LINES_PER_FILE) {
          for (var b = 0; b < block.length; b++) out.push(block[b]);
        } else {
          var keptCount = DIFF_LINES_PER_FILE;
          for (var b2 = 0; b2 < keptCount; b2++) out.push(block[b2]);
          var truncated = block.length - keptCount;
          out.push('[' + truncated + ' lines truncated, full diff in worktree]');
        }
        block = [];
      }
      for (var i = 0; i < lines.length; i++) {
        if (/^diff --git /.test(lines[i])) {
          flushBlock();
        }
        block.push(lines[i]);
      }
      flushBlock();
      return out.join('\n');
    }
    
    // Find: keep first 50 + last 10 + total count.
    function filterFind(output, cmd) {
      if (typeof output !== 'string') return output;
      if (!matches(FIND_PATTERN, cmd)) return output;
      if (output.length <= DEFAULT_THRESHOLD) return output;
    
      var lines = output.split('\n');
      // Strip a single trailing empty line so the count reflects real entries.
      var hadTrailingNewline = lines.length > 0 && lines[lines.length - 1] === '';
      if (hadTrailingNewline) lines.pop();
      var n = lines.length;
      if (n <= FIND_HEAD + FIND_TAIL) return output;
    
      var head = lines.slice(0, FIND_HEAD);
      var tail = lines.slice(n - FIND_TAIL);
      var omitted = n - FIND_HEAD - FIND_TAIL;
      var marker = '[' + omitted + ' lines truncated, ' + n + ' total entries]';
      var result = head.concat([marker], tail).join('\n');
      if (hadTrailingNewline) result += '\n';
      return result;
    }
    
    // Curl: keep status + headers + first 1024 chars of body + truncation marker.
    // Body is delimited from headers by the first blank line.
    function filterCurl(output, cmd) {
      if (typeof output !== 'string') return output;
      if (!matches(CURL_PATTERN, cmd)) return output;
      if (output.length <= CURL_THRESHOLD) return output;
    
      // Split headers from body at
  • hooks/progress-tracker.jsRunsGitHub
    Read the script
    #!/usr/bin/env node
    // PostToolUse hook -- zero-context progress tracker
    // Writes to .forge/.progress.json and stderr ONLY. Zero stdout = zero context tokens.
    // Matcher: "*" (all tools)
    
    const fs = require('fs');
    const path = require('path');
    
    const FORGE_DIR = '.forge';
    const PROGRESS_FILE = path.join(FORGE_DIR, '.progress.json');
    
    let input = '';
    const timeout = setTimeout(() => process.exit(0), 3000);
    process.stdin.setEncoding('utf8');
    process.stdin.on('data', chunk => input += chunk);
    process.stdin.on('end', () => {
      clearTimeout(timeout);
      try {
        const data = JSON.parse(input);
        const toolName = data.tool_name || 'unknown';
    
        if (!fs.existsSync(FORGE_DIR)) process.exit(0);
    
        let progress = {
          started: null,
          tool_calls: 0,
          tools_by_type: {},
          current_phase: 'unknown',
          current_task: null,
          tasks_completed: 0,
          last_tool: null,
          last_tool_time: null,
          files_modified: [],
          commits: 0,
          test_runs: 0,
          test_pass_rate: null,
          elapsed_sec: 0
        };
        if (fs.existsSync(PROGRESS_FILE)) {
          try { progress = JSON.parse(fs.readFileSync(PROGRESS_FILE, 'utf8')); } catch (e) {}
        }
    
        if (!progress.started) progress.started = Date.now();
        progress.tool_calls++;
        progress.tools_by_type[toolName] = (progress.tools_by_type[toolName] || 0) + 1;
        progress.last_tool = toolName;
        progress.last_tool_time = new Date().toISOString();
        progress.elapsed_sec = Math.round((Date.now() - progress.started) / 1000);
    
        // Track commits
        const cmd = data.tool_input?.command || '';
        if (toolName === 'Bash' && /git commit/.test(cmd)) {
          progress.commits++;
        }
    
        // Track test runs
        if (toolName === 'Bash' && /vitest|jest|pytest|cargo test|go test|npm test/.test(cmd)) {
          progress.test_runs++;
          const output = typeof data.tool_output === 'string' ? data.tool_output : '';
          const passMatch = output.match(/(\d+) passed/);
          const failMatch = output.match(/(\d+) failed/);
          if (passMatch) {
            const passed = parseInt(passMatch[1]);
            const failed = failMatch ? parseInt(failMatch[1]) : 0;
            progress.test_pass_rate = Math.round((passed / (passed + failed)) * 100);
          }
        }
    
        // Track file modifications
        if (['Edit', 'Write'].includes(toolName)) {
          const fp = data.tool_input?.file_path;
          if (fp && !progress.files_modified.includes(fp)) {
            progress.files_modified.push(fp);
            if (progress.files_modified.length > 50) {
              progress.files_modified = progress.files_modified.slice(-50);
            }
          }
        }
    
        // Read state for task info
        const statePath = path.join(FORGE_DIR, 'state.md');
        if (fs.existsSync(statePath)) {
          try {
            const stateText = fs.readFileSync(statePath, 'utf8');
            const phaseMatch = stateText.match(/^phase:\s*(.+)$/m);
            const taskMatch = stateText.match(/^current_task:\s*(.+)$/m);
            if (phaseMatch) progress.current_phase = phaseMatch[1].trim();
            if (taskMatch) progress.current_task = taskMatch[1].trim();
          } catch (e) {}
        }
    
        fs.writeFileSync(PROGRESS_FILE, JSON.stringify(progress, null, 2));
    
        // Write to stderr only -- zero context cost
        const min = Math.floor(progress.elapsed_sec / 60);
        const sec = progress.elapsed_sec % 60;
        const summary = `[Forge] ${min}m${sec}s | ${progress.tool_calls} tools | ${progress.commits} commits | ${progress.files_modified.length} files | task: ${progress.current_task || 'none'}`;
        process.stderr.write(summary + '\n');
    
        // EXIT 0 WITH NO STDOUT -- zero token cost
      } catch (e) {
        // Silent failure
      }
      process.exit(0);
    });
    
  • hooks/stop-hook.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    set -euo pipefail
    
    # Forge Stop Hook -- Smart Loop Engine
    # Fires when Claude tries to exit. Reads state, routes to next action.
    #
    # T013 additions (R003, R005, R007):
    #   - Lock heartbeat update on every invocation (R007)
    #   - First-invocation lock acquisition with takeover of stale locks (R007)
    #   - New phase handling: budget_exhausted, conflict_resolution, recovering, lock_conflict
    #   - Honors empty stdout from `route` (T010 exit-action signal) as a clean exit (R003)
    #   - Releases lock on completion or clean exit
    #
    # Graceful degradation: any failure of the new lock/heartbeat helpers must NOT
    # break existing routing. Errors are logged to .forge-debug.log and silently
    # tolerated unless they represent a hard lock conflict.
    
    PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
    FORGE_DIR=".forge"
    LOOP_FILE="${FORGE_DIR}/.forge-loop.json"
    LOCK_FILE="${FORGE_DIR}/.forge-loop.lock"
    STATE_FILE="${FORGE_DIR}/state.md"
    TOOLS_CJS="${PLUGIN_ROOT}/scripts/forge-tools.cjs"
    # Fix #3: Log errors to debug file instead of /dev/null
    DEBUG_LOG="${FORGE_DIR}/.forge-debug.log"
    
    # Read hook input from stdin
    INPUT=$(cat)
    # T013: portable stdin reading. `/dev/stdin` is mangled to `C:\dev\stdin` by
    # Git Bash on Windows; use fd 0 via process.stdin streaming instead.
    _PARSE_JSON_FIELD='let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{try{const o=JSON.parse(d);console.log(o[process.argv[1]]||"")}catch(e){console.log("")}})'
    SESSION_ID=$(printf '%s' "$INPUT" | node -e "$_PARSE_JSON_FIELD" session_id 2>/dev/null || echo "")
    TRANSCRIPT_PATH=$(printf '%s' "$INPUT" | node -e "$_PARSE_JSON_FIELD" transcript_path 2>/dev/null || echo "")
    
    # Not in a forge loop? Allow normal exit
    [ ! -f "$LOOP_FILE" ] && exit 0
    
    # Check for Ralph Loop conflict
    if [ -f ".claude/ralph-loop.local.md" ]; then
      echo '{"decision":"block","reason":"WARNING: Ralph Loop is also active. Please run /cancel-ralph first, then /forge resume. Only one loop plugin should be active at a time."}'
      exit 0
    fi
    
    # Read loop state
    LOOP_DATA=$(cat "$LOOP_FILE")
    ITERATION=$(echo "$LOOP_DATA" | node -e "try{const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(d.iteration||1)}catch(e){console.log(1)}")
    MAX_ITERATIONS=$(echo "$LOOP_DATA" | node -e "try{const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(d.max_iterations||100)}catch(e){console.log(100)}")
    COMPLETION_PROMISE=$(echo "$LOOP_DATA" | node -e "try{const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(d.completion_promise||'FORGE_COMPLETE')}catch(e){console.log('FORGE_COMPLETE')}")
    LOOP_SESSION=$(echo "$LOOP_DATA" | node -e "try{const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));console.log(d.session_id||'')}catch(e){console.log('')}")
    
    # Session isolation -- only the owning session controls the loop
    if [ -n "$LOOP_SESSION" ] && [ -n "$SESSION_ID" ] && [ "$LOOP_SESSION" != "$SESSION_ID" ]; then
      exit 0
    fi
    
    # === T013: Read current phase from state.md ===
    # Used to short-circuit on terminal/error phases like budget_exhausted or lock_conflict.
    CURRENT_PHASE=$(node -e "
      try {
        const t = require('fs').readFileSync('${STATE_FILE}', 'utf8');
        const m = t.match(/^---\n([\s\S]*?)\n---/);
        if (!m) { console.log(''); process.exit(0); }
        const fm = m[1];
        const pm = fm.match(/^phase:\s*(.+)$/m);
        console.log(pm ? pm[1].trim() : '');
      } catch (e) { console.log(''); }
    " 2>/dev/null || echo "")
    
    # === T013: Lock acquisition / heartbeat (R007) ===
    # Lock ownership across short-lived stop-hook node processes is identified by
    # the session id stored in the lock's `task` field as `session:<SESSION_ID>`.
    # This is necessary because each invocation has a different node PID.
    LOCK_OWNER_TAG="session:${SESSION_ID:-unknown}"
    
    LOCK_RESULT=$(node -e "
      const path = require('path');
      const tools = require('${TOOLS_CJS}'.replace(/\\\\/g, '/'));
      const forgeDir = '${FORGE_DIR}';
      const ownerTag = '${LOCK_OWNER_TAG}';
      try {
        const existing = tools.readLock(forgeDir);
        if (!existing) {
          // Fresh acquire on first invocation of this session.
          const r = tools.acquireLock(forgeDir, ownerTag);
          if (r.acquired) {
            console.log(JSON.stringify({ status: 'acquired', tookOverStale: !!r.tookOverStale }));
          } else {
            console.log(JSON.stringify({ status: 'conflict', reason: r.reason || 'unknown', holder: r.holder || null }));
          }
          process.exit(0);
        }
        // Lock exists. If it belongs to this session, refresh heartbeat by
        // rewriting the lock file (works across PIDs).
        if (existing.task === ownerTag) {
          const fs = require('fs');
          const lockPath = path.join(forgeDir, '.forge-loop.lock');
          const refreshed = [
            'pid: ' + process.pid,
            'started: ' + (existing.started || new Date().toISOString()),
            'task: ' + ownerTag,
            'heartbeat: ' + new Date().toISOString(),
            ''
          ].join('\n');
          fs.writeFileSync(lockPath, refreshed);
          console.log(JSON.stringify({ status: 'heartbeat' }));
          process.exit(0);
        }
        // Lock owned by someone else. Check staleness.
        const stale = tools.detectStaleLock(forgeDir);
        if (stale && stale.is_stale) {
          const r = tools.acquireLock(forgeDir, ownerTag);
          if (r.acquired) {
            console.log(JSON.stringify({ status: 'acquired', tookOverStale: true, prior: existing.task || '' }));
          } else {
            console.log(JSON.stringify({ status: 'conflict', reason: r.reason || 'takeover_failed', holder: existing }));
          }
          process.exit(0);
        }
        console.log(JSON.stringify({ status: 'conflict', reason: 'held_by_other_session', holder: existing }));
      } catch (e) {
        console.log(JSON.stringify({ status: 'error', message: String(e && e.message || e) }));
      }
    " 2>>"$DEBUG_LOG" || echo '{"status":"error","message":"node_invocation_failed"}')
    
    LOCK_STATUS=$(printf '%s' "$LOCK_RESULT" | node -e "$_PARS
  • hooks/test-output-filter.jsRunsGitHub
    Read the script
    #!/usr/bin/env node
    // PostToolUse hook -- filters test output to show only failures + summary
    // Matcher: "Bash"
    //
    // Reads PostToolUse JSON from stdin. If the tool was a Bash invocation running
    // a recognised test runner and the output exceeds 2000 chars, replaces the
    // output with a condensed view: failure blocks with context + summary tail.
    // Outputs nothing (exit 0) when not applicable -- zero context cost.
    
    'use strict';
    
    // --- constants -----------------------------------------------------------
    
    var CHAR_THRESHOLD = 2000;
    var CONTEXT_LINES = 8;
    var SUMMARY_TAIL = 10;
    var TIMEOUT_MS = 3000;
    
    // Test runner patterns matched against the beginning of the command string.
    // Order does not matter -- first match wins.
    var TEST_COMMANDS = [
      /\bvitest\b/,
      /\bjest\b/,
      /\bpytest\b/,
      /\bcargo\s+test\b/,
      /\bgo\s+test\b/,
      /\bnpm\s+test\b/,
      /\bnpx\s+test\b/,
      /\bmocha\b/
    ];
    
    // Lines that signal a failure -- kept case-insensitive where noted.
    var FAILURE_PATTERNS = [
      /\bFAIL\b/i,
      /\bFAILED\b/i,
      /\bERROR\b/i,
      /AssertionError/,
      /AssertError/,
      /TypeError/,
      /ReferenceError/,
      /Expected[\s\S]*Received/,
      /^>/,                  // code-frame pointer
      /\bnot ok\b/           // TAP format failure
    ];
    
    // Lines that are passing noise -- skip these during filtering.
    var PASS_PATTERNS = [
      /^\s*[✓✔]\s/,         // checkmark reporters
      /\bPASS\b/,
      /^\s*ok\s+\d+/,       // TAP pass
      /^\.+$/                // dot reporter
    ];
    
    // --- helpers -------------------------------------------------------------
    
    function isTestCommand(cmd) {
      if (typeof cmd !== 'string') return false;
      for (var i = 0; i < TEST_COMMANDS.length; i++) {
        if (TEST_COMMANDS[i].test(cmd)) return true;
      }
      return false;
    }
    
    function isFailureLine(line) {
      for (var i = 0; i < FAILURE_PATTERNS.length; i++) {
        if (FAILURE_PATTERNS[i].test(line)) return true;
      }
      return false;
    }
    
    function isPassLine(line) {
      for (var i = 0; i < PASS_PATTERNS.length; i++) {
        if (PASS_PATTERNS[i].test(line)) return true;
      }
      return false;
    }
    
    // --- main ----------------------------------------------------------------
    
    function main() {
      // Safety timeout -- never block the flow for more than 3 seconds
      var timer = setTimeout(function () {
        process.exit(0);
      }, TIMEOUT_MS);
      // Allow the process to exit naturally even if the timer is pending
      if (timer.unref) timer.unref();
    
      var chunks = [];
      process.stdin.setEncoding('utf8');
      process.stdin.on('data', function (chunk) {
        chunks.push(chunk);
      });
    
      process.stdin.on('end', function () {
        clearTimeout(timer);
        try {
          var raw = chunks.join('');
          if (!raw.trim()) {
            process.exit(0);
            return;
          }
    
          var payload = JSON.parse(raw);
    
          // Only act on Bash tool invocations
          var toolName = payload.tool_name || '';
          if (toolName !== 'Bash') {
            process.exit(0);
            return;
          }
    
          // Extract command and output
          var toolInput = payload.tool_input || {};
          var command = toolInput.command || '';
          var toolOutput = payload.tool_output || '';
    
          if (!isTestCommand(command)) {
            process.exit(0);
            return;
          }
    
          // Small outputs pass through unchanged -- not worth filtering
          if (toolOutput.length <= CHAR_THRESHOLD) {
            process.exit(0);
            return;
          }
    
          // --- filter the output -----------------------------------------------
          var lines = toolOutput.split('\n');
          var originalCount = lines.length;
    
          // Mark failure lines and their context windows
          var keep = {};
          for (var i = 0; i < lines.length; i++) {
            if (isFailureLine(lines[i]) && !isPassLine(lines[i])) {
              var start = Math.max(0, i - CONTEXT_LINES);
              var end = Math.min(lines.length - 1, i + CONTEXT_LINES);
              for (var j = start; j <= end; j++) {
                keep[j] = true;
              }
            }
          }
    
          // Always keep the summary tail (last N lines)
          var tailStart = Math.max(0, lines.length - SUMMARY_TAIL);
          for (var t = tailStart; t < lines.length; t++) {
            keep[t] = true;
          }
    
          // Build filtered output with separator markers for skipped regions
          var filtered = [];
          var lastKept = -1;
          for (var k = 0; k < lines.length; k++) {
            if (keep[k]) {
              if (lastKept >= 0 && k - lastKept > 1) {
                var skipped = k - lastKept - 1;
                filtered.push('  ... (' + skipped + ' lines filtered) ...');
              }
              filtered.push(lines[k]);
              lastKept = k;
            }
          }
    
          var filteredCount = filtered.length;
    
          // Build header
          var header = '[Test Output Filtered: ' + originalCount + ' lines -> ' + filteredCount + ' lines (showing failures + summary)]';
          var result = header + '\n' + filtered.join('\n');
    
          // Produce hook output
          var output = {
            hookSpecificOutput: {
              hookEventName: 'PostToolUse',
              additionalContext: result
            }
          };
    
          process.stdout.write(JSON.stringify(output) + '\n');
          process.exit(0);
    
        } catch (e) {
          // Graceful degradation -- never break the flow
          process.exit(0);
        }
      });
    
      process.stdin.on('error', function () {
        process.exit(0);
      });
    }
    
    main();
    
  • hooks/token-monitor.shRunsGitHub
    Read the script
    #!/usr/bin/env bash
    set -euo pipefail
    
    # Forge PostToolUse hook: per-task token tracking + 80/100 percent gates.
    #
    # T012 / R001. Runs on every tool use, so it must stay well under 50ms.
    # Fast-paths:
    #   1. exit immediately if forge loop not active
    #   2. exit immediately if no current_task in state.md (regular sessions)
    #   3. only spawn node when there is real work to do
    #
    # Heavy work (transcript scan, depth downgrade) still lives in stop-hook.sh.
    # This hook only does the cheap inline tracking the spec requires.
    
    FORGE_DIR=".forge"
    LOOP_FILE="${FORGE_DIR}/.forge-loop.json"
    STATE_FILE="${FORGE_DIR}/state.md"
    COUNTER_FILE="${FORGE_DIR}/.tool-count"
    TOOLS_SCRIPT="${CLAUDE_PLUGIN_ROOT:-.}/scripts/forge-tools.cjs"
    
    # Fast path 1: forge not active. Drain stdin so the producer does not block.
    if [ ! -f "$LOOP_FILE" ]; then
      cat >/dev/null 2>&1 || true
      exit 0
    fi
    
    # Read hook payload from stdin once. We need its length for cheap token
    # estimation, and we do not need to parse the JSON in bash.
    PAYLOAD="$(cat 2>/dev/null || true)"
    
    # Preserve existing behavior: increment cheap tool counter.
    COUNT=0
    if [ -f "$COUNTER_FILE" ]; then
      COUNT="$(cat "$COUNTER_FILE" 2>/dev/null || echo 0)"
    fi
    echo $((COUNT + 1)) > "$COUNTER_FILE"
    
    # Fast path 2: no state file means no per-task tracking possible.
    if [ ! -f "$STATE_FILE" ]; then
      exit 0
    fi
    
    # Extract current_task from frontmatter. Cheap: read first ~20 lines only,
    # match the key, strip whitespace and quotes. No python, no node.
    CURRENT_TASK="$(sed -n '1,25p' "$STATE_FILE" \
      | grep -E '^current_task:' \
      | head -n1 \
      | sed -e 's/^current_task:[[:space:]]*//' -e 's/["'\'']//g' -e 's/[[:space:]]*$//' \
      || true)"
    
    # Fast path 3: no current task means nothing to record.
    if [ -z "${CURRENT_TASK:-}" ]; then
      exit 0
    fi
    
    # Cheap token estimation: chars / 4 (industry rule of thumb). The PostToolUse
    # payload contains both tool_input and tool_response, which is what consumes
    # context, so its length is a usable proxy.
    PAYLOAD_LEN=${#PAYLOAD}
    TOKENS=$(( PAYLOAD_LEN / 4 ))
    if [ "$TOKENS" -le 0 ]; then
      TOKENS=1
    fi
    
    # Fast path 4: forge-tools missing. Fail open so user sessions never break.
    if [ ! -f "$TOOLS_SCRIPT" ]; then
      exit 0
    fi
    
    # Single node spawn: record tokens, check budget, emit gate decision.
    # Output format from forge-tools: pct=<f> used=<i> budget=<i> warn=<0|1> escalated=<0|1>
    RESULT="$(node "$TOOLS_SCRIPT" record-task-tokens "$CURRENT_TASK" "$TOKENS" --forge-dir "$FORGE_DIR" 2>/dev/null || true)"
    
    if [ -z "$RESULT" ]; then
      # forge-tools missing the subcommand or failed. Stay silent.
      exit 0
    fi
    
    # Parse the key=value line without spawning anything.
    PCT=""
    BUDGET=""
    WARN="0"
    ESCALATED="0"
    for kv in $RESULT; do
      case "$kv" in
        pct=*)       PCT="${kv#pct=}" ;;
        budget=*)    BUDGET="${kv#budget=}" ;;
        warn=*)      WARN="${kv#warn=}" ;;
        escalated=*) ESCALATED="${kv#escalated=}" ;;
      esac
    done
    
    # 100% circuit breaker. State.md was already updated by forge-tools. Emit
    # a loud warning to stderr so Claude sees it on the next prompt cycle.
    if [ "$ESCALATED" = "1" ]; then
      echo "[budget exhausted] task ${CURRENT_TASK} hit ${PCT}% of ${BUDGET} tokens. state set to budget_exhausted. stop hook will route." 1>&2
      exit 0
    fi
    
    # 80% warning gate. Caveman form per R013: short, no articles, fragments ok.
    if [ "$WARN" = "1" ]; then
      echo "[budget warning] task ${CURRENT_TASK} at ${PCT}% of ${BUDGET} tokens. wrap up or escalate." 1>&2
    fi
    
    exit 0
    
  • hooks/tool-cache-store.jsRunsGitHub
  • hooks/tool-cache.jsRunsGitHub

All 8 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 withlucasduys-forge

Turn a one-line idea into a branch with tested, reviewed, committed code. The brainstorm-to-commit pipeline for Claude Code.

Get the whole plugin, auto-invoked