Hooks
What session-orchestrator runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
> /plugin marketplace add Kanevry/session-orchestrator > /plugin install session-orchestrator@kanevry
Ships with session-orchestrator. 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.
- Matches
startup|resume|clear|compactecho '๐ฏ Session Orchestrator v5.3.0 โ /session [housekeeping|feature|deep] | /plan [new|feature|retro] | /discovery [scope] | /evolve [analyze|review|list]'sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/on-session-start.mjs"
SessionEnd
sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/on-session-end.mjs"
PreToolUse
- Matches
Skillsh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/skill-invocation-telemetry.mjs" - Matches
Edit|Write|MultiEditsh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/enforce-scope.mjs"sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/config-protection.mjs" - Matches
Bashsh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-destructive-guard.mjs"sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-staging-fence.mjs"sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-memory-propose-audit.mjs"sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-sessions-ledger-guard.mjs"sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-templates-first.mjs"sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/pre-bash-issue-budget.mjs"sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/enforce-commands.mjs" - Matches
Agentsh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/pre-task-scope-disjoint.mjs" - Matches
AskUserQuestionsh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/pre-auq-clarity.mjs"
PostToolUse
- Matches
Edit|Writesh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/post-edit-validate.mjs" - Matches
Edit|Write|MultiEditsh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/post-tooluse-frontend-slop.mjs"sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/post-edit-import-probe.mjs" - Matches
Bashsh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/post-bash-write-verify.mjs" - Matches
*sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/loop-guard.mjs"
Stop
sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/on-stop.mjs"
SubagentStop
sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/on-stop.mjs"sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/subagent-telemetry.mjs"sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/post-subagent-discovery-validator.mjs"
PostToolUseFailure
sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/post-tool-failure-corrective-context.mjs"sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/post-bash-issue-budget-refund.mjs"
PostToolBatch
sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/post-tool-batch-wave-signal.mjs"sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/operator-steer.mjs"
SubagentStart
sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/subagent-telemetry.mjs"
CwdChanged
sh "$CLAUDE_PLUGIN_ROOT/hooks/run-node.sh" "$CLAUDE_PLUGIN_ROOT/hooks/cwd-change-restore.mjs"
Where it lives
- hooks/agent-teams-h3-test.shGitHub
Read the script
#!/usr/bin/env bash # hooks/agent-teams-h3-test.sh โ Empirical H3 hook-seam test harness for Agent Teams Adapter # # Per ADR 0002 (issue #484): verify that a TaskCompleted exit-2 hook reliably blocks # task completion + feeds back to the teammate across 3 repeat runs (lagging-task-status race). # # This is a DRY-RUN harness. Live Agent Teams execution is interactive and cannot be # fully automated from bash. The script: # 1. Verifies preconditions (claude-code version, experimental flag availability) # 2. Sets up the test team scaffold at ~/.claude/teams/h3-test-deep3/ # 3. Prints the manual 3-run procedure for the operator to execute # 4. Generates a JSONL log template for capturing results # # Exit codes: # 0 โ preconditions met, scaffold created, ready for manual 3-run # 1 โ preconditions failed (version too low, flag unrecognized, etc.) # 2 โ scaffold creation failed (permission/path issue) set -euo pipefail TEAM_NAME="h3-test-deep3" TEAM_DIR="${HOME}/.claude/teams/${TEAM_NAME}" LOG_TEMPLATE_PATH=".orchestrator/research/h3-hook-seam-test-template.jsonl" MIN_CLAUDE_VERSION="2.1.32" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- print_header() { echo "" echo "============================================================" echo " H3 Hook-Seam Test Harness โ Agent Teams Adapter (ADR 0002)" echo "============================================================" echo "" echo "Purpose: Verify that a TaskCompleted exit-2 hook reliably blocks" echo " task completion and delivers feedback to the teammate," echo " across 3 repeat runs (lagging-task-status race check)." echo "" echo "This is a DRY-RUN setup harness. Live execution is interactive." echo "" } version_gte() { # Returns 0 if $1 >= $2 (semver comparison, major.minor.patch) local actual="$1" local required="$2" # Use sort -V to compare; if the required version comes first (or is equal), # the actual version is sufficient. local lowest lowest="$(printf '%s\n%s\n' "$actual" "$required" | sort -V | head -n1)" [ "$lowest" = "$required" ] } # --------------------------------------------------------------------------- # Step 1: Precondition check # --------------------------------------------------------------------------- precondition_check() { echo "=== Step 1: Precondition Check ===" echo "" local failed=0 # 1a. claude-code binary present if ! command -v claude >/dev/null 2>&1; then echo "[FAIL] 'claude' binary not found on PATH." echo " Install Claude Code >= ${MIN_CLAUDE_VERSION} and ensure it is on your PATH." failed=1 else # 1b. Version check local raw_version raw_version="$(claude --version 2>/dev/null | head -n1)" || true # Extract the semver portion (e.g. "2.1.144" from "2.1.144 (Claude Code)") local actual_version actual_version="$(echo "${raw_version}" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1)" || true if [ -z "${actual_version}" ]; then echo "[FAIL] Could not parse version from: '${raw_version}'" echo " Expected format: '2.1.144 (Claude Code)'" failed=1 elif version_gte "${actual_version}" "${MIN_CLAUDE_VERSION}"; then echo "[PASS] claude version: ${actual_version} >= ${MIN_CLAUDE_VERSION} (minimum required)" else echo "[FAIL] claude version: ${actual_version} < ${MIN_CLAUDE_VERSION} (minimum required)" echo " Please upgrade Claude Code before running the H3 test." failed=1 fi fi # 1c. CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS recognized in binary # We check via 'strings' (available on macOS/Linux) rather than actually # setting the env-var, since setting it alone has no side effects but # we want to confirm the flag is compiled in โ not just accepted silently. echo "" echo "Checking CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS flag recognition..." local claude_bin claude_bin="$(command -v claude 2>/dev/null)" || true if [ -n "${claude_bin}" ] && command -v strings >/dev/null 2>&1; then local flag_hits flag_hits="$(strings "${claude_bin}" 2>/dev/null | grep -c 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS' || true)" if [ "${flag_hits}" -ge 1 ]; then echo "[PASS] CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS is compiled into the binary (${flag_hits} occurrences)" else echo "[WARN] Could not confirm flag in binary via 'strings'. This may be a macOS SIP or binary format issue." echo " Proceeding; flag may still be recognized at runtime." fi else echo "[INFO] 'strings' not available or claude binary not found โ skipping binary flag scan." echo " Flag recognition was confirmed empirically at W1 D2 (session 2026-05-19-deep-2)." fi # 1d. No existing ~/.claude/teams/ team with this name (avoid clobber) echo "" if [ -d "${TEAM_DIR}" ]; then echo "[WARN] ${TEAM_DIR} already exists. Scaffold step will skip overwriting existing files." else echo "[INFO] ${TEAM_DIR} does not exist โ scaffold will create it fresh." fi echo "" if [ "${failed}" -eq 1 ]; then echo "[FAIL] One or more preconditions not met. Resolve issues above before proceeding." return 1 fi echo "[PASS] All preconditions met." return 0 } # --------------------------------------------------------------------------- # Step 2: Scaffold the test team directory # --------------------------------------------------------------------------- scaffold_team_dir() { echo "=== Step 2: Team Scaffold ===" echo "" echo "Creating team scaffold at: ${TEAM_DIR}" echo "" # Use a subshell so set -e applies; any failure returns 2 to caller via trap if ! mkdir -p "${TEAM_DIR}"; then echo "[FAIL] Could not create ${TEAM_DIR} โ check permissions." return 2 fi # Write the hooks config that will exercise the H3 seam. # NOTE: This is the INTENDED config shape; the ope - hooks/config-protection.mjsGitHub
Read the script
#!/usr/bin/env node /** * config-protection.mjs โ PreToolUse Edit|Write|MultiEdit guard (ecc-analysis / #622). * * The edit-tool analogue of the test-the-mock gate-cheating anti-pattern. * Intercepts Edit/Write on a small allow-list of quality-gate config files * (eslint / vitest / tsconfig / prettier / commitlint / gitleaks) and WARNs โ * or, in `strict` mode, BLOCKS โ when an edit LOOSENS a quality gate: * 1. a coverage/threshold number is lowered; * 2. a disable/ignore directive (eslint-disable, @ts-ignore, prettier-ignore, * โฆ) is ADDED (count increase, not mere presence); * 3. a lint rule is removed or turned off (error/warn โ off/0/false); * 4. a `.gitleaks.toml` allowlist is widened (new allowlist/regex/path/stopword * entries); * 5. tsconfig strictness is relaxed (strict flags flipped trueโfalse, removed, * or skipLibCheck added true). * * First-time creation (no prior file), tightening, neutral/comment-only edits, * non-config files, and unparseable content are ALWAYS allowed. The guard is * warn-by-default advisory โ a low-false-positive line/regex heuristic, NOT an * exhaustive AST gate (YAGNI). Fail-open on any internal error: a legit edit is * never blocked by a guard bug. * * Edit/MultiEdit are compared WHOLE-FILE, not slice-only: the on-disk file * (still pre-edit at PreToolUse time) is read as old, the supplied slice * replacement(s) are applied in order to synthesise new, and detectLoosening * runs over the full contents. This defeats slice-boundary bypasses where the * key name / rule lives outside the caller-chosen old_stringโnew_string window * (e.g. `old_string:"90" new_string:"10"` or deleting a whole threshold line). * * Decision precedence: * 1. shouldRunHook('config-protection') gate โ exit 0 when disabled. * 2. config-protection.enabled (false โ allow silently). * 3. allow-config-weakening: true Session Config bypass โ allow + โน note. * 4. heuristic โ warn (stderr + event) | strict-block (event + deny envelope). * * Exit code is ALWAYS 0 (post-#906). A strict-mode block is signalled solely by * the nested PreToolUse deny envelope emitDeny() writes to stdout โ the docs * forbid the old stdout-JSON + `exit 2` mixed form ("Exit 2 โฆ Claude Code * ignores stdout and any JSON in it"), which discarded the reason and looked * like a crash to the operator. Do not reintroduce `exit 2` here. */ import { shouldRunHook } from './_lib/profile-gate.mjs'; import { isMainModule } from '../scripts/lib/is-main-module.mjs'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import { emitAllow, emitDeny } from '../scripts/lib/io.mjs'; import { emitEvent } from '../scripts/lib/events.mjs'; import { getProjectDir } from '../scripts/lib/platform.mjs'; import { _parseConfigProtection, _isConfigWeakeningAllowed, } from '../scripts/lib/config/config-protection.mjs'; // --------------------------------------------------------------------------- // Protected-file allow-list (matched against path.basename(file_path)) // --------------------------------------------------------------------------- /** * Exact basenames that are always protected. * package.json + pyproject.toml are deliberately EXCLUDED โ they change for * many non-gate reasons and would be a false-positive magnet (#622 scope). */ const PROTECTED_EXACT = new Set([ 'vitest.config.base.ts', '.gitleaks.toml', 'gitleaks.toml', ]); /** * Regex matchers for the protected basenames with variant extensions. * Kept anchored + linear-time (no nested quantifiers) โ ReDoS-safe. */ const PROTECTED_PATTERNS = [ /^eslint\.config\.(?:js|mjs|cjs|ts|mts|cts)$/, /^\.eslintrc(?:\..+)?$/, // .eslintrc, .eslintrc.json, .eslintrc.js, .eslintrc.cjs, โฆ /^vitest\.config\.(?:ts|js|mjs)$/, /^tsconfig.*\.json$/, // tsconfig.json, tsconfig.base.json, tsconfig.build.json, โฆ /^\.prettierrc(?:\..+)?$/, // .prettierrc, .prettierrc.json, .prettierrc.js, โฆ /^prettier\.config\.(?:js|cjs|mjs)$/, /^commitlint\.config\.(?:js|cjs|mjs|ts)$/, ]; /** * Is the given file path a protected quality-gate config file? * @param {string} filePath * @returns {boolean} */ function isProtectedConfig(filePath) { if (typeof filePath !== 'string' || !filePath) return false; const base = path.basename(filePath); if (PROTECTED_EXACT.has(base)) return true; return PROTECTED_PATTERNS.some((re) => re.test(base)); } // --------------------------------------------------------------------------- // stdin reading (inline null-on-failure โ never throw; mirrors the // post-tool-batch-wave-signal.mjs:55 pattern so a malformed payload allows) // --------------------------------------------------------------------------- /** * Read stdin to EOF (best-effort). Returns parsed JSON or null on failure. * @returns {Promise<object|null>} */ function readStdinJson() { return new Promise((resolve) => { if (process.stdin.readableEnded || process.stdin.closed) { resolve(null); return; } const chunks = []; const timer = setTimeout(() => { resolve(null); }, 5_000); process.stdin.setEncoding('utf8'); process.stdin.on('data', (c) => chunks.push(c)); process.stdin.on('end', () => { clearTimeout(timer); const raw = chunks.join('').trim(); if (!raw) { resolve(null); return; } try { resolve(JSON.parse(raw)); } catch { resolve(null); } }); process.stdin.on('error', () => { clearTimeout(timer); resolve(null); }); process.stdin.resume(); }); } // --------------------------------------------------------------------------- // Loosening heuristic // --------------------------------------------------------------------------- /** * Disable/ignore directive TOKENS. Counted (not merely presence-checked) so an * edit only flags when it ADDS more directives than the old content had โ prose * mentioning the word "disable" cannot trip the guard because we match the * directive token itself. */ const - hooks/cwd-change-restore.mjsGitHub
Read the script
#!/usr/bin/env node /** * cwd-change-restore.mjs โ CwdChanged hook handler. * * Hook event: CwdChanged (issue #342). * Fires when the coordinator's working directory changes unexpectedly. * Records the event in `.orchestrator/current-session.json` under the * `cwd_changes` array so the coordinator and downstream skills can * inspect recent directory changes without re-reading the full event log. * * Note: the actual CWD restoration is a harness-level concern; this * handler is informational only. It mirrors the pattern used by * post-tool-failure-corrective-context.mjs. * * Decision flow: * 1. shouldRunHook gate โ exit 0 immediately when the hook is disabled. * 2. Read JSON payload from stdin: { previous_cwd, new_cwd }. * 3. Build a compact record: { timestamp, previous_cwd, new_cwd }. * 4. Atomic read-modify-write of .orchestrator/current-session.json: * append to `cwd_changes` array (create if absent), keep last 20 * entries to bound file growth. * 5. Output: nothing on stdout. Diagnostic errors to stderr only. * * Exit codes: 0 always (informational, never blocking). */ import path from 'node:path'; import { shouldRunHook } from './_lib/profile-gate.mjs'; import { isMainModule } from '../scripts/lib/is-main-module.mjs'; import { getProjectDir } from '../scripts/lib/platform.mjs'; import { atomicMutateJson } from './_lib/atomic-json.mjs'; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Maximum number of cwd_change entries retained per session. */ const MAX_ENTRIES = 20; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Read stdin to EOF (best-effort). Returns parsed JSON or null on failure. * Uses a 5 s timeout consistent with Claude Code hook contract. * * @returns {Promise<object|null>} */ function readStdinJson() { return new Promise((resolve) => { if (process.stdin.readableEnded || process.stdin.closed) { resolve(null); return; } const chunks = []; const timer = setTimeout(() => { resolve(null); }, 5_000); process.stdin.setEncoding('utf8'); process.stdin.on('data', (c) => chunks.push(c)); process.stdin.on('end', () => { clearTimeout(timer); const raw = chunks.join('').trim(); if (!raw) { resolve(null); return; } try { resolve(JSON.parse(raw)); } catch { resolve(null); } }); process.stdin.on('error', () => { clearTimeout(timer); resolve(null); }); process.stdin.resume(); }); } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main() { const input = await readStdinJson(); const previousCwd = typeof input?.previous_cwd === 'string' ? input.previous_cwd : null; const newCwd = typeof input?.new_cwd === 'string' ? input.new_cwd : null; const record = { timestamp: new Date().toISOString(), previous_cwd: previousCwd, new_cwd: newCwd, }; const sessionFile = path.join(getProjectDir(), '.orchestrator', 'current-session.json'); const result = await atomicMutateJson(sessionFile, {}, (current) => { const existing = Array.isArray(current.cwd_changes) ? current.cwd_changes : []; // Append the new record and cap at MAX_ENTRIES (keep most-recent). const updated = [...existing, record].slice(-MAX_ENTRIES); return { ...current, cwd_changes: updated }; }, 'cwd'); // Nothing else depends on this write โ a non-ENOENT read/parse failure // just means the record is dropped this turn. Diagnostic only (stderr), // matches the hook's own "never blocking" contract. if (!result.ok) { console.error(`cwd-change-restore: atomicMutateJson skipped write (${result.reason})`); } } // Exit 0 always โ informational hook must never block Claude. // Entry guard (#1393): run only when this file IS the script node was invoked // with โ every harness path execs it (`sh run-node.sh <this file>`). A bare // `import()` (a probe, a test, a curious agent) must neither run main() nor // tear the importing process down. The profile gate sits INSIDE the guard for // that second reason: at module top level its `process.exit(0)` exited every // process that merely imported this hook. if (isMainModule(import.meta.url)) { if (!shouldRunHook('cwd-change-restore')) process.exit(0); main().catch(() => {}).finally(() => process.exit(0)); } - hooks/enforce-commands.mjsGitHub
Read the script
#!/usr/bin/env node /** * enforce-commands.mjs โ PreToolUse hook: blocks dangerous Bash commands. * * Node.js port of hooks/enforce-commands.sh. Part of v3.0.0 migration * (Epic #124, issue #138). ESM, Node 20+, no external dependencies beyond stdlib. * * Decision flow (8 gates, early-exit): * G1 tool filter โ only Bash tool is gated * G2 command present + string * G3 wave-scope.json exists * G4 command-guard gate enabled * G5 enforcement != "off" * G6 blocked pattern match against .blockedCommands[], or * fallback safety list when .blockedCommands is empty * G7 strict โ deny; warn โ stderr + allow; otherwise allow * * DECISION CHANNEL (post-#906): a deny is signalled by the single nested * PreToolUse JSON envelope emitDeny() writes to stdout, with exit **0** โ * NOT by exit 2. The docs forbid the mixed form ("Exit 2 โฆ Claude Code * ignores stdout and any JSON in it"), which silently discarded the reason * and surfaced to the operator as a crash. Do not reintroduce `exit 2` here. * Corollary: exit 0 alone no longer distinguishes allow from deny โ the * envelope's presence does, and a malformed envelope fails OPEN. * * SECURITY-REQ-01: try/catch on main(). emitDeny on any unhandled error โ * fail-closed, never a bare exit 1. Null-guard readStdin() return. * SECURITY-REQ-07: FALLBACK_BLOCKED includes 'git push -f' and 'drop table' * (short form + case variant gaps in the original Bash fallback list). * SECURITY-REQ-08: scope file read exactly once per invocation. */ import { shouldRunHook } from './_lib/profile-gate.mjs'; // Static for the SAME reason profile-gate.mjs is (#993, see the late-binding // block below): a leaf predicate with ZERO repo imports (node:fs + node:url // only) that decides whether this hook runs at all. Everything carrying a // transitive repo graph stays late-bound inside bootstrap(). import { isMainModule } from '../scripts/lib/is-main-module.mjs'; /** * sha256(command), auf 16 Hex-Zeichen gekuerzt. * * WORTGLEICH zu `hashCommand()` in `hooks/pre-bash-destructive-guard.mjs:245` * (das seinerseits `loop-guard.mjs` hashArgs() spiegelt). Bewusst dupliziert * statt geteilt: ein Hook darf beim Start nicht an einem weiteren Modul * haengen, das fehlen kann โ die drei Zeilen sind billiger als ein * Ladefehler auf dem PreToolUse-Pfad. * * WARUM ES DEN HELFER HIER BRAUCHT (2026-09-19, EventDrop #1140): * Die `foreign_session_ignored`-Nutzlast dieses Hooks trug bis heute das * ROHE Kommando. Gemessen in EventDrop.at: 3.816 Zeilen in der getrackten * `.orchestrator/metrics/events.jsonl` mit rohem `command`, darin 24 * distinkte ECHTE Produktions-Share-Codes aus 17 fremden Kundenkonten und * ein protokollierter `select access_pin_hash`. Bei 23 dieser Events ist * der Share-Code die vollstaendige Capability โ `/event/<code>` oeffnet das * Album ohne Anmeldung. Das Journal ist getrackt und geht bei jedem Klon mit. * Der Geschwisterhook `pre-bash-destructive-guard.mjs` machte es von Anfang * an richtig und sagt es im eigenen Kopf: โPayload never includes the raw * command โ only a truncated sha256 command_hash." * * Der Hash haelt das Ereignis ZAEHLBAR und GRUPPIERBAR โ genau die * Eigenschaft, fuer die es laut `docs/scope-collision-guard.md` existiert. * Das rohe Kommando wurde von keinem Konsumenten gelesen. * * @param {string} command * @returns {string} */ function hashCommand(command) { return crypto.createHash('sha256').update(command).digest('hex').slice(0, 16); } import crypto from 'node:crypto'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; // --------------------------------------------------------------------------- // #993 โ late-bound repo dependencies // // These used to be STATIC imports. A SyntaxError in any of them failed at ESM // LINK time, before the first statement here ran: node exited 1 with 0 bytes on // stdout, and the `main().catch(...)` handler at the bottom of this file was // structurally unreachable (it only covers runtime errors inside `main()`). // Under the exit-0 PreToolUse protocol (#906) that crash is, on the only // decision-bearing channel, INDISTINGUISHABLE from an explicit `emitAllow()` โ // the guard failed open and SILENTLY. This is the sibling defect #992 fixed for // pre-bash-destructive-guard; #993 generalises the same repair here. // // Binding them late (dynamic `import()` inside `bootstrap()`, below) turns that // link-time crash into a catchable runtime error, which is what makes the // GUARD INACTIVE banner in `_lib/guard-source-loader.mjs` reachable at all. // // `profile-gate.mjs` stays static on purpose โ it has ZERO imports of its own // and gates whether this hook runs at all. // --------------------------------------------------------------------------- /** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin; /** @type {typeof import('../scripts/lib/io.mjs').emitAllow} */ let emitAllow; /** @type {typeof import('../scripts/lib/io.mjs').emitDeny} */ let emitDeny; /** @type {typeof import('../scripts/lib/io.mjs').emitWarn} */ let emitWarn; let resolveProjectDir; let readJson; let findScopeFile; let extractBashWriteTargets; let pathMatchesPattern; /** @type {typeof import('../scripts/lib/session-identity/own-session.mjs').readProcessLocalSessionIds} */ let readProcessLocalSessionIds; /** @type {typeof import('../scripts/lib/session-identity/own-session.mjs').classifyManifestSession} */ let classifyManifestSession; /** * The `command-blocker.mjs` namespace, imported DIRECTLY (not via the * hardening.mjs barrel โ which does not carry the `headFallback` recovery, and * which transitively imports command-blocker via scope-gate.mjs, so it fails * anyway when command-blocker breaks). Mirrors the direct binding in * pre-bash-destructive-guard / sessions-ledger-guard. Held as ONE object so the * required-export list lives in exactly one place: the `requires` array on the * `blocker` spec - hooks/enforce-scope.mjsGitHub
Read the script
#!/usr/bin/env node /** * enforce-scope.mjs โ PreToolUse hook: block Edit/Write/MultiEdit outside allowed wave paths. * * Replaces enforce-scope.sh (87-line Bash). Part of v3.0.0 Windows-native migration. * Issue: github.com/Kanevry/session-orchestrator/issues/137 * * Decision flow (8 gates + one pre-gate, early-exit): * G1 tool filter โ only Edit/Write/MultiEdit are gated * G2 file_path present + string * G3 wave-scope.json exists * G3b (#1123, #1194) the manifest belongs to THIS session โ a manifest that * PROVABLY names another live session in this shared working copy is not * ours to enforce; allow + emit one event. Runs AFTER the parse so a * corrupt manifest still fails closed. Identity is PROCESS-LOCAL only * (hook payload + CLAUDE_CODE_SESSION_ID), never the repo-global * `session.lock` โ see the gate's own block for why. * G4 path-guard gate enabled * G5 enforcement != "off" * G5b (#792) allowlist-first: an EXPLICIT absolute allowedPaths entry that * matches the fully realpath-resolved candidate โ allow, BEFORE G6. * Runs before G6 so a deliberate out-of-repo grant (e.g. a vault path) * is reachable at all โ G6 would otherwise deny every out-of-repo path * without ever consulting allowedPaths. See matchedAbsoluteGrant. * (#1398 cond. 4) The matched grant is GRADED through the shared * `gradeScopeEntry` predicate; an `error` verdict emits ONE WARN and the * write is still ALLOWED โ this gate never denies on a grading verdict. * G5c (#1295) out-of-root carveout for THIS repo's Claude Code auto-memory * directory (`~/.claude/projects/<encoded-repo-path>/memory/`). Harness- * owned, lives outside the working copy, cannot collide with any wave * scope. Evaluated inside G6's out-of-root branch only; a SIBLING repo's * memory dir and every other out-of-repo path stay denied. * G6 resolved path inside project root * G7 relative path matches an allowedPaths pattern * G8 (all passed) โ allow * * Empty-allowedPaths reasoning (#1057): the VERDICT for an empty allowlist is * unchanged (deny-all, the #256 contract), but the deny REASON is now classified * โ Discovery's read-only contract, an unreadable manifest, a crashed session's * leftover, an incomplete `--union`, or undecidable. See * `scripts/lib/scope-gate.mjs` ยง Empty-`allowedPaths` classification. * * Exit codes: 0 = allow 2 = deny * * SECURITY notes (inline refs): * REQ-01 top-level try/catch โ emitDeny on unexpected error (fail-closed) * REQ-03 realpath(dirname) to resolve symlinks; ENOENT โ fall back to path.resolve * REQ-04 relativeFromRoot() === null โ deny as outside-root * REQ-05 normalize path separators to "/" before pathMatchesPattern (Windows compat) * REQ-06 relative file_path resolved against projectRoot, not process.cwd() * REQ-08 wave-scope.json read once; parsed object passed to all gate checks * REQ-09 (#792) G5b out-of-repo carveout is structurally safe: RELATIVE * allowedPaths entries can NEVER match an out-of-repo path (the * isAbsolute filter drops them), so `**` / `../**` cannot be used to * escape the repo; only entries that are themselves absolute match, * and only against their own literal (canonical/realpath) subtree. * Empty absolute set โ the pre-gate is inert (byte-identical to the * pre-#792 behaviour). * * Coordinator carveout (#245): a short, explicit list of harness-owned files * bypasses Gate 7 (allowedPaths glob) โ specifically STATE.md across all platform * state dirs and the wave-scope.json manifest itself. Coordinators write these * between waves as part of the harness protocol; subjecting them to per-wave * allowedPaths would force every wave plan to re-list harness infrastructure. * Project-root containment (Gate 6) and enforcement-off (Gate 5) still apply. * No wildcards โ exact string match only. */ import path from 'node:path'; import { promises as fs } from 'node:fs'; import { pathToFileURL } from 'node:url'; import { shouldRunHook } from './_lib/profile-gate.mjs'; // Static for the SAME reason profile-gate.mjs is (#993, see the late-binding // block below): a leaf predicate with ZERO repo imports (node:fs + node:url // only) that decides whether this hook runs at all. Everything carrying a // transitive repo graph stays late-bound inside bootstrap(). import { isMainModule } from '../scripts/lib/is-main-module.mjs'; // --------------------------------------------------------------------------- // #993 โ late-bound repo dependencies // // These used to be STATIC imports. A SyntaxError in any of them failed at ESM // LINK time, before the first statement here ran: node exited 1 with 0 bytes on // stdout, and the `main().catch(...)` handler at the bottom of this file was // structurally unreachable. Under the exit-0 PreToolUse protocol (#906) that // crash is, on the only decision-bearing channel, INDISTINGUISHABLE from an // explicit `emitAllow()` โ the guard failed open and SILENTLY. This is the // sibling defect #992 fixed for pre-bash-destructive-guard; #993 generalises the // same repair here. // // Binding them late (dynamic `import()` inside `bootstrap()`, below) turns that // link-time crash into a catchable runtime error, which is what makes the // GUARD INACTIVE banner in `_lib/guard-source-loader.mjs` reachable at all. // // BANNER-ONLY (#993 D1): this hook consumes ZERO command-blocker symbols, so no // module here opts into the `git show HEAD:` fallback โ every load failure // degrades straight to GUARD INACTIVE, never DEGRADED. // // `profile-gate.mjs` and `node:*` builtins stay static โ they cannot be the // broken repo module. // --------------------------------------------------------------------------- /** @type {typeof import('../scripts/lib/io.mjs').readStdin} */ let readStdin; /** @type {typeof impor - hooks/loop-guard.mjsGitHub
- hooks/on-session-end.mjsGitHub
- hooks/on-session-start.mjsGitHub
- hooks/on-stop.mjsGitHub
- hooks/operator-steer.mjsGitHub
- hooks/post-bash-issue-budget-refund.mjsGitHub
- hooks/post-bash-write-verify.mjsGitHub
- hooks/post-edit-import-probe.mjsGitHub
- hooks/post-edit-validate.mjsGitHub
- hooks/post-subagent-discovery-validator.mjsGitHub
- hooks/post-tool-batch-wave-signal.mjsGitHub
- hooks/post-tool-failure-corrective-context.mjsGitHub
- hooks/post-tooluse-frontend-slop.mjsGitHub
- hooks/pre-auq-clarity.mjsGitHub
- hooks/pre-bash-destructive-guard.mjsGitHub
- hooks/pre-bash-issue-budget.mjsGitHub
- hooks/pre-bash-memory-propose-audit.mjsGitHub
- hooks/pre-bash-sessions-ledger-guard.mjsGitHub
- hooks/pre-bash-staging-fence.mjsGitHub
- hooks/pre-bash-templates-first.mjsGitHub
- hooks/pre-task-scope-disjoint.mjsGitHub
- hooks/run-node.shRunsGitHub
Read the script
#!/bin/sh # hooks/run-node.sh โ resolve the Node.js binary robustly, then exec a plugin hook. # # Why this exists (GH Kanevry/session-orchestrator#53): the Claude Code harness # executes hook commands via `/bin/sh -c` with the PATH of the harness process # itself. That shell does NOT source ~/.zshrc / ~/.bashrc, so Node installed via # Homebrew on Apple Silicon (/opt/homebrew/bin), nvm, volta, or asdf may be # invisible to hooks even though `node` works fine in a normal terminal. A bare # `node ...` command then fails with "node: command not found" on EVERY hook of # EVERY tool call โ loud, repetitive, and useless to the operator. # # Contract: # sh run-node.sh <hook-script.mjs> [args...] # - Resolution order: $SO_NODE_BIN override > PATH > well-known install dirs # ($SO_NODE_SEARCH_DIRS, colon-separated, overrides the built-in list) > nvm. # - Found: exec's node โ stdin/stdout/stderr and exit code pass through # unchanged (PreToolUse exit-2 blocking still works). # - Missing: prints ONE warning per rate-limit window (marker file in # ${TMPDIR:-/tmp}, 6h TTL) and exits 0 so hooks degrade gracefully # instead of spamming a shell error on every tool call. # 1. Explicit operator override wins. if [ -n "$SO_NODE_BIN" ] && [ -x "$SO_NODE_BIN" ]; then exec "$SO_NODE_BIN" "$@" fi # 2. PATH as inherited from the harness. if command -v node >/dev/null 2>&1; then exec node "$@" fi # 3. Well-known install locations (Homebrew arm64/intel, system, volta, asdf). search_dirs="${SO_NODE_SEARCH_DIRS:-/opt/homebrew/bin:/usr/local/bin:/usr/bin:${VOLTA_HOME:-$HOME/.volta}/bin:$HOME/.asdf/shims:$HOME/.local/bin}" old_ifs="$IFS" IFS=: for dir in $search_dirs; do if [ -n "$dir" ] && [ -x "$dir/node" ]; then IFS="$old_ifs" exec "$dir/node" "$@" fi done IFS="$old_ifs" # 4. nvm keeps versioned dirs; any installed version is good enough for hooks. # (Glob order is lexical, not semver โ acceptable for a last-resort fallback.) for cand in "${NVM_DIR:-$HOME/.nvm}"/versions/node/*/bin/node; do if [ -x "$cand" ]; then exec "$cand" "$@" fi done # 5. Not found anywhere: degrade gracefully. Warn at most once per 6 hours so # the operator gets ONE actionable diagnostic instead of per-tool-call spam. # Deliberately dependency-free: a PATH broken enough to lose `node` may also # lack `touch`/`find`/`id`, so the marker is written via shell redirection # and `find` only upgrades the check to a 6h TTL when it happens to exist. marker="${TMPDIR:-/tmp}/session-orchestrator-node-missing-${USER:-uid}" if [ -f "$marker" ]; then expired="$(find "$marker" -mmin +360 2>/dev/null || true)" else expired="yes" fi if [ -n "$expired" ]; then : > "$marker" 2>/dev/null || true { echo "session-orchestrator: 'node' not found on the hook PATH โ plugin hooks are skipped." echo " Fix: install Node.js 24+, expose it on the harness PATH, or set SO_NODE_BIN=/abs/path/to/node." echo " (Hook shells do not source ~/.zshrc โ Homebrew/nvm/volta installs can be invisible here." echo " Rate-limited to once per 6h. See README.md ยง Troubleshooting.)" } >&2 fi exit 0 - hooks/skill-invocation-telemetry.mjsGitHub
- hooks/subagent-telemetry.mjsGitHub
- hooks/wave-scope-commit-guard.mjsGitHub
All 30 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.
Give your agents a working rhythm. You type three commands: /session reads your repository, your open issues and the last session, proposes what to work on, and waits for your correction.
Repo: Kanevry/session-orchestrator

