#!/usr/bin/env node /** * PreToolUse Hook: OMC Reminder Enforcer (Node.js) * Injects contextual reminders before every tool execution * Cross-platform: Windows, macOS, Linux */ import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, writeFileSync } from 'fs'; import { createHash } from 'crypto'; import { dirname, join, resolve, basename } from 'path'; import { homedir } from 'os'; import { execSync } from 'child_process'; import { fileURLToPath, pathToFileURL } from 'url'; import { getClaudeConfigDir } from './lib/config-dir.mjs'; import { encodeProjectPath } from './lib/encode-project-path.mjs'; import { evaluateAgentHeavyPreflight } from './lib/pre-tool-enforcer-preflight.mjs'; import { evaluateForceAgentDelegation } from './lib/force-agent-delegation-preflight.mjs'; import { resolveOmcStateRoot } from './lib/state-root.mjs'; import { readStdin } from './lib/stdin.mjs'; import { resolveConfiguredAgentModel } from './lib/agent-model-config.mjs'; // Inlined from src/config/models.ts — avoids a dist/ import so the hook works // before a build and stays consistent with the TypeScript source. function isProviderSpecificModelId(modelId) { if (/^((us|eu|ap|global)\.anthropic\.|anthropic\.claude)/i.test(modelId)) return true; if (/^arn:aws(-[^:]+)?:bedrock:/i.test(modelId)) return true; if (modelId.toLowerCase().startsWith('vertex_ai/')) return true; return false; } function hasExtendedContextSuffix(modelId) { return /\[\d+[mk]\]$/i.test(modelId); } function isSubagentSafeModelId(modelId) { return isProviderSpecificModelId(modelId) && !hasExtendedContextSuffix(modelId); } function isBedrockProviderEnv() { if (process.env.CLAUDE_CODE_USE_BEDROCK === '1') return true; const modelId = process.env.CLAUDE_MODEL || process.env.ANTHROPIC_MODEL || ''; if (/^((us|eu|ap|global)\.anthropic\.|anthropic\.claude)/i.test(modelId)) return true; if ( /^arn:aws(-[^:]+)?:bedrock:/i.test(modelId) && /:(inference-profile|application-inference-profile)\//i.test(modelId) && modelId.toLowerCase().includes('claude') ) { return true; } return false; } function isVertexProviderEnv() { if (process.env.CLAUDE_CODE_USE_VERTEX === '1') return true; const modelId = process.env.CLAUDE_MODEL || process.env.ANTHROPIC_MODEL || ''; return !!modelId && modelId.toLowerCase().startsWith('vertex_ai/'); } function getActiveModelIds() { return [process.env.CLAUDE_MODEL || '', process.env.ANTHROPIC_MODEL || ''].filter(Boolean); } function isNormalClaudeModelId(modelId) { const lower = (modelId || '').toLowerCase(); return Boolean(lower) && lower.includes('claude') && !isProviderSpecificModelId(modelId); } function hasNormalClaudeActiveModel() { return getActiveModelIds().some(isNormalClaudeModelId); } function isConfigForceInheritProxyEnv() { const config = loadOmcConfig(); return config.routing?.forceInherit === true && !hasNormalClaudeActiveModel(); } function isNonClaudeProviderEnv() { if (isBedrockProviderEnv() || isVertexProviderEnv()) return true; const modelId = process.env.CLAUDE_MODEL || process.env.ANTHROPIC_MODEL || ''; if (modelId && !modelId.toLowerCase().includes('claude')) return true; const baseUrl = process.env.ANTHROPIC_BASE_URL || ''; if (baseUrl && !baseUrl.includes('anthropic.com')) return true; return isConfigForceInheritProxyEnv(); } function acceptsProxyAnthropicDefaultTierValue(key, value) { return key.startsWith('ANTHROPIC_DEFAULT_') && Boolean(value) && isNonClaudeProviderEnv() && !isBedrockProviderEnv() && !isVertexProviderEnv(); } const TIER_ALIASES = new Set(['sonnet', 'opus', 'haiku', 'fable']); function isTierAlias(modelId) { return TIER_ALIASES.has((modelId || '').toLowerCase()); } // Resolution chain for tier alias → subagent-safe model ID. // Order mirrors src/config/models.ts:TIER_ENV_KEYS with OMC_SUBAGENT_MODEL as top-priority override. // OMC_SUBAGENT_MODEL at position 0 wins for ALL tiers — tier-specific vars are only // reached when it is unset or fails isSubagentSafeModelId validation. // OMC_MODEL_* is intentionally excluded: those are OMC-internal vars that the OMC bridge // reads for its own routing, but CC itself does not read them when resolving tier aliases // (sonnet/haiku/opus). Allowing OMC_MODEL_* as proof would let the hook pass while CC // still fails to route the alias, reintroducing the downstream deadlock this gate prevents. const TIER_TO_DEFAULT_ENV_KEYS = { haiku: ['OMC_SUBAGENT_MODEL', 'CLAUDE_CODE_BEDROCK_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL'], sonnet: ['OMC_SUBAGENT_MODEL', 'CLAUDE_CODE_BEDROCK_SONNET_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL'], opus: ['OMC_SUBAGENT_MODEL', 'CLAUDE_CODE_BEDROCK_OPUS_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL'], fable: ['OMC_SUBAGENT_MODEL', 'CLAUDE_CODE_BEDROCK_FABLE_MODEL', 'ANTHROPIC_DEFAULT_FABLE_MODEL'], }; function resolveTierAliasToSafeModel(tierAlias) { const keys = TIER_TO_DEFAULT_ENV_KEYS[(tierAlias || '').toLowerCase()]; if (!keys) return ''; for (const key of keys) { const value = (process.env[key] || '').trim(); // CC-native vars (ANTHROPIC_DEFAULT_* and CLAUDE_CODE_BEDROCK_*) are read by CC's own // model resolution, which handles [1m] suffixes correctly for explicit model= calls. // OMC-internal vars (OMC_SUBAGENT_MODEL, OMC_MODEL_*) are not read by CC, so a [1m] // value there is not a valid routing proof — keep the stricter isSubagentSafeModelId check. const isAnthropicDefaultTierVar = key.startsWith('ANTHROPIC_DEFAULT_'); const isNativeCcVar = isAnthropicDefaultTierVar || key.startsWith('CLAUDE_CODE_BEDROCK_'); const validator = isNativeCcVar ? isProviderSpecificModelId : isSubagentSafeModelId; if (value && (validator(value) || acceptsProxyAnthropicDefaultTierValue(key, value))) return value; } return ''; } /** Map a bare Anthropic model ID to its CC tier alias (sonnet/opus/haiku/fable), or null if unrecognised. */ function normalizeToCcAlias(model) { if (!model) return null; const lower = model.toLowerCase(); if (lower.includes('opus')) return 'opus'; if (lower.includes('sonnet')) return 'sonnet'; if (lower.includes('haiku')) return 'haiku'; if (lower.includes('fable')) return 'fable'; return null; } /** * Read the `model:` field from an OMC agent definition's YAML frontmatter. * Returns the raw model string (e.g. "claude-opus-4-6") or null if not found. */ function readAgentDefinitionModel(subagentType) { // Guard: subagent_type must be a string — non-string payloads would throw on .replace() // and the catch block would silently return {continue:true}, bypassing enforcement. const agentType = (typeof subagentType === 'string' ? subagentType : '').replace(/^oh-my-claudecode:/, ''); if (!agentType) return null; // Reject path traversal: agent names are simple identifiers; no path separators allowed. if (!/^[a-zA-Z0-9_-]+$/.test(agentType)) return null; // Build a prioritised list of agents/ directories to search. // CLAUDE_PLUGIN_ROOT is tried first when set; the script-relative path is always the // final fallback. Checking per-file (not just per-directory) means a partially-populated // plugin install doesn't hide agents that exist in the script-relative tree. const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT; const scriptAgentsDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'agents'); const candidateDirs = [ ...(pluginRoot ? [join(pluginRoot, 'agents')] : []), scriptAgentsDir, ]; const agentFile = candidateDirs.map(d => join(d, `${agentType}.md`)).find(f => existsSync(f)) ?? null; try { if (!agentFile) return null; const content = readFileSync(agentFile, 'utf-8').replace(/^\uFEFF/, ''); // Extract the YAML frontmatter block (content between the opening and closing ---). // Searching the whole file would match `model:` lines in the body/prompt text, causing // false denies for agents whose prompt happens to contain that word. const fmMatch = content.match(/^---[\r\n]+([\s\S]*?)[\r\n]+---/); if (!fmMatch) return null; // Strip surrounding quotes so `model: "global.anthropic.claude-sonnet-4-6"` and // `model: global.anthropic.claude-sonnet-4-6` are treated identically. const modelMatch = fmMatch[1].match(/^model:\s*(\S+)/m); return modelMatch ? modelMatch[1].trim().replace(/^["']|["']$/g, '') : null; } catch { return null; } } const SLOP_RISK_TOOL_NAMES = new Set([ 'Task', 'TaskCreate', 'TaskUpdate', 'Agent', 'Bash', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', ]); // Keep the SLOP trigger tied to actual fallback/workaround semantics. // Primary-path domain names and comments often use neutral qualifiers such as // "extra" or "additional"; those words alone must not enter this gate. const SLOP_FALLBACK_LANGUAGE_PATTERN = /\b(?:fallback|fall\s+back|workaround|work\s+around)\b/i; const SLOP_FALLBACK_ACTION_PATTERNS = [ /\b(?:add|build|create|implement|introduce|make|patch|use|using|write)\s+(?:an?\s+|the\s+)?(?:fallback|workaround)\b/i, /\b(?:fallback|workaround)\s+(?:layer|path|handler|shim|patch|implementation|mechanism|mode)\b/i, /\bworkaround\s+(?:it|this|that|the|a|an)\b/i, /\b(?:fall\s+back|fallback)\s+(?:to|on|onto)\b/i, /\bwork\s+around\s+(?:it|this|that|the|a|an)\b/i, /\bwork\s+around\s+(?!(?:it|this|that|the|a|an)\b)(?:[a-z0-9][\w-]*\s+){0,5}[a-z0-9][\w-]*\b/i, /(?:^|[\s"'`=:/\\])[\w.-]*(?:fallback|workaround)[\w.-]*\.(?:cjs|js|mjs|py|sh|ts|tsx)\b/i, ]; const SLOP_BENIGN_TECHNICAL_PATTERNS = [ /\bfail[-\s]?soft\s+fallback(?:\s+(?:value|behavior|behaviour|result|semantics?))?\b/i, /\bfallback\s+(?:value|variable|parameter|argument|option|setting|config(?:uration)?|default)\b/i, /\bfallback\s+to\s+(?:the\s+)?default(?:\s+(?:config(?:uration)?|settings?|value|behavior|behaviour|option))?\b/i, /\b(?:workaround|work\s+around)\s+for\s+(?:commit|change|issue|bug|regression|version|release|pr|pull\s+request|#[0-9]+|[a-f0-9]{7,40}\b)/i, /\b(?:memory|sql|sqlite|mysql|postgres(?:ql)?|typescript|node|browser|runtime)\s+workaround\b/i, ]; const SLOP_DOC_CONTEXT_PATTERN = /(?:^|[/\\])(?:docs?|documentation|guides?|instructions?|prompts?|\.om[ctx])(?:[/\\]|$)|\.(?:md|mdx|txt|rst)$/i; const SLOP_SELF_REFERENCE_PATH_PATTERN = /(?:^|[/\\])(?:pre-tool-enforcer(?:\.mjs)?|pre-tool-enforcer\.test\.ts)(?:$|[/\\])/i; function collectStringValues(value, output = [], depth = 0) { if (depth > 5 || output.length > 100) return output; if (typeof value === 'string') { output.push(value); return output; } if (Array.isArray(value)) { for (const item of value) collectStringValues(item, output, depth + 1); return output; } if (value && typeof value === 'object') { for (const [key, child] of Object.entries(value)) { // Skip hook/runtime metadata so warnings are driven by user-authored tool intent. if (/^(cwd|directory|session_?id|transcript_?path|hook_event_name)$/i.test(key)) continue; collectStringValues(child, output, depth + 1); } } return output; } function collectLikelyPathValues(value, output = [], depth = 0) { if (depth > 5 || output.length > 100 || !value || typeof value !== 'object') return output; if (Array.isArray(value)) { for (const item of value) collectLikelyPathValues(item, output, depth + 1); return output; } for (const [key, child] of Object.entries(value)) { if (typeof child === 'string' && /(?:^|_)(?:file_?path|path|filename|target|command)$/i.test(key)) { output.push(child); continue; } collectLikelyPathValues(child, output, depth + 1); } return output; } function stripSlopQuotedAndCodeContexts(text) { return text .replace(/```[\s\S]*?```/g, '\n') .replace(/`[^`\r\n]*`/g, ' ') .replace(/(["'])(?:\\.|(?!\1)[^\\\r\n])*\1/g, ' '); } function splitSlopInspectionSegments(text) { return text .split(/[\r\n!?;]+/) .map(segment => segment.trim()) .filter(Boolean); } function removeBenignTechnicalSlopFallbackSpans(text) { return SLOP_BENIGN_TECHNICAL_PATTERNS.reduce( (result, pattern) => { const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`; return result.replace(new RegExp(pattern.source, flags), ' '); }, text, ); } function hasSlopFallbackActionShape(text) { const strippedText = stripSlopQuotedAndCodeContexts(text); return splitSlopInspectionSegments(strippedText).some(segment => ( SLOP_FALLBACK_ACTION_PATTERNS.some(pattern => ( pattern.test(removeBenignTechnicalSlopFallbackSpans(segment)) )) )); } function isSelfReferentialSlopContext(toolInput) { return collectLikelyPathValues(toolInput).some(value => SLOP_SELF_REFERENCE_PATH_PATTERN.test(value)); } function isDocumentationSlopContext(toolInput) { const pathLikeValues = collectLikelyPathValues(toolInput); return pathLikeValues.some(value => SLOP_DOC_CONTEXT_PATTERN.test(value)); } function shouldWarnForSlopFallbackLanguage(data, toolName, inspectedText) { if (!SLOP_RISK_TOOL_NAMES.has(toolName)) return false; if (!SLOP_FALLBACK_LANGUAGE_PATTERN.test(inspectedText)) return false; const toolInput = data.toolInput || data.tool_input || {}; if (isSelfReferentialSlopContext(toolInput)) return false; if (isDocumentationSlopContext(toolInput)) { return false; } return hasSlopFallbackActionShape(inspectedText); } function generateSlopWarning(data, toolName) { const toolInput = data.toolInput || data.tool_input || {}; const promptLikeFields = { prompt: data.prompt, userPrompt: data.userPrompt, user_prompt: data.user_prompt, message: data.message, }; const inspectedText = collectStringValues(toolInput) .concat(collectStringValues(promptLikeFields)) .join('\n'); if (!shouldWarnForSlopFallbackLanguage(data, toolName, inspectedText)) return ''; return '[SLOP WARNING] Detected fallback/workaround language in this tool input. ' + 'Do not make potential slop: avoid ad-hoc fallback layers, workaround shims, or environment-specific patches unless explicitly justified. ' + 'For architecture concerns, consult the architect for a concrete design first. ' + 'If this seems environment-specific, ask the user to confirm constraints before proceeding.'; } function combineHookMessages(...messages) { return messages.filter(Boolean).join('\n\n'); } const ADVISORY_THROTTLE_STATE_FILE = 'pre-tool-advisory-throttle.json'; const ADVISORY_THROTTLE_MAX_ENTRIES = 100; const ADVISORY_THROTTLE_DEFAULT_COOLDOWN_MS = 5 * 60 * 1000; const ADVISORY_THROTTLE_MIN_PRUNE_WINDOW_MS = 60 * 60 * 1000; function getAdvisoryThrottleCooldownMs() { const raw = process.env.OMC_PRE_TOOL_ADVISORY_COOLDOWN_MS; if (raw == null || raw === '') return ADVISORY_THROTTLE_DEFAULT_COOLDOWN_MS; const parsed = Number.parseInt(raw, 10); if (!Number.isFinite(parsed)) return ADVISORY_THROTTLE_DEFAULT_COOLDOWN_MS; return Math.max(0, parsed); } function getAdvisoryThrottleNowMs() { const raw = process.env.OMC_PRE_TOOL_ADVISORY_NOW_MS; if (raw != null && raw !== '') { const parsed = Number.parseInt(raw, 10); if (Number.isFinite(parsed)) return parsed; } return Date.now(); } function getAdvisoryThrottlePath(stateDir, sessionId) { const safeSessionId = isValidSessionId(sessionId) ? sessionId : ''; return safeSessionId ? join(stateDir, 'sessions', safeSessionId, ADVISORY_THROTTLE_STATE_FILE) : join(stateDir, ADVISORY_THROTTLE_STATE_FILE); } function advisoryThrottleKey(message) { return createHash('sha256').update(message).digest('hex'); } function normalizeAdvisoryThrottleState(state) { if (!state || typeof state !== 'object' || !state.entries || typeof state.entries !== 'object') { return { version: 1, entries: {} }; } return { ...state, version: 1, entries: state.entries }; } function pruneAdvisoryThrottleEntries(entries, nowMs, cooldownMs) { const pruneWindowMs = Math.max(cooldownMs * 2, ADVISORY_THROTTLE_MIN_PRUNE_WINDOW_MS); const freshEntries = Object.entries(entries) .filter(([, entry]) => { const last = Number(entry?.last_emitted_at_ms); return Number.isFinite(last) && last <= nowMs && nowMs - last <= pruneWindowMs; }) .sort(([, a], [, b]) => Number(b?.last_emitted_at_ms || 0) - Number(a?.last_emitted_at_ms || 0)) .slice(0, ADVISORY_THROTTLE_MAX_ENTRIES); return Object.fromEntries(freshEntries); } function shouldEmitAdvisoryMessage(stateDir, sessionId, message) { const cooldownMs = getAdvisoryThrottleCooldownMs(); if (!message || cooldownMs <= 0) return true; const nowMs = getAdvisoryThrottleNowMs(); const throttlePath = getAdvisoryThrottlePath(stateDir, sessionId); const key = advisoryThrottleKey(message); try { const state = normalizeAdvisoryThrottleState(readJsonFile(throttlePath)); state.entries = pruneAdvisoryThrottleEntries(state.entries, nowMs, cooldownMs); const previous = state.entries[key]; const previousMs = Number(previous?.last_emitted_at_ms); const shouldEmit = !Number.isFinite(previousMs) || previousMs > nowMs || nowMs - previousMs >= cooldownMs; if (shouldEmit) { state.entries[key] = { last_emitted_at_ms: nowMs, message, }; state.entries = pruneAdvisoryThrottleEntries(state.entries, nowMs, cooldownMs); state.updated_at = new Date(nowMs).toISOString(); mkdirSync(dirname(throttlePath), { recursive: true }); const tmpPath = `${throttlePath}.tmp`; writeFileSync(tmpPath, JSON.stringify(state, null, 2), { mode: 0o600 }); renameSync(tmpPath, throttlePath); } return shouldEmit; } catch { // Fail open: advisory throttling must never silence safety output because // state IO failed. The hook may repeat a nudge rather than risk hiding it. return true; } } const SESSION_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/; const MODE_STATE_FILES = [ 'autopilot-state.json', 'ultrapilot-state.json', 'ralph-state.json', 'ultragoal-state.json', 'ultrawork-state.json', 'ultraqa-state.json', 'pipeline-state.json', 'team-state.json', 'omc-teams-state.json', ]; const QUIET_LEVEL = getQuietLevel(); const BUILT_IN_TASK_LIST_TOOL_NAMES = new Set([ 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet', 'TaskOutput', 'TaskStop', ]); function getQuietLevel() { const parsed = Number.parseInt(process.env.OMC_QUIET || '0', 10); if (Number.isNaN(parsed)) return 0; return Math.max(0, parsed); } /** * Resolve the .omc root directory for a given starting directory. * * Resolution order (mirrors src/lib/worktree-paths.ts getOmcRoot): * 1) OMC_STATE_DIR env — log a warning and fall through (full project-id * derivation lives in the TS layer; use resolveOmcStateRoot() for async * TS-backed OMC_STATE_DIR support in main()). * 2) Walk up from startDir looking for a .omc-workspace marker file. * The first directory containing that file is the workspace anchor. * 3) git rev-parse --show-toplevel from startDir. * 4) Fallback to startDir itself. * * @param {string} startDir - Directory to resolve from (usually cwd from hook payload) * @returns {string} Absolute path to the .omc root directory */ function resolveOmcRoot(startDir) { const dir = startDir || process.cwd(); // 1) OMC_STATE_DIR: full project-id derivation is TS-only; warn and fall through. if (process.env.OMC_STATE_DIR) { process.stderr.write( '[omc] OMC_STATE_DIR is set; resolveOmcRoot() falling through to workspace-marker ' + 'resolution. Use resolveOmcStateRoot() for full OMC_STATE_DIR support.\n' ); } // 2) Walk up looking for .omc-workspace marker try { let cursor = resolve(dir); const home = (() => { try { return resolve(homedir()); } catch { return null; } })(); while (true) { if (existsSync(join(cursor, '.omc-workspace'))) { return join(cursor, '.omc'); } const parent = dirname(cursor); if (parent === cursor) break; if (home && cursor === home) break; cursor = parent; } } catch { // walk failed — continue to git fallback } // 3) git rev-parse --show-toplevel try { const top = execSync('git rev-parse --show-toplevel', { cwd: dir, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 5000, windowsHide: true, }).trim(); if (top) return join(top, '.omc'); } catch { // not in a git repo — fall through } // 4) Fallback to startDir return join(dir, '.omc'); } /** * Resolve transcript path in worktree environments. * Mirrors logic used by context safety/guard hooks. */ function resolveTranscriptPath(transcriptPath, cwd) { if (!transcriptPath) return transcriptPath; try { if (existsSync(transcriptPath)) return transcriptPath; } catch { /* fallthrough */ } const worktreePattern = /--claude-worktrees-[^/\\]+/; if (worktreePattern.test(transcriptPath)) { const resolvedPath = transcriptPath.replace(worktreePattern, ''); try { if (existsSync(resolvedPath)) return resolvedPath; } catch { /* fallthrough */ } } const effectiveCwd = cwd || process.cwd(); try { const gitCommonDir = execSync('git rev-parse --git-common-dir', { cwd: effectiveCwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, }).trim(); const absoluteCommonDir = resolve(effectiveCwd, gitCommonDir); const mainRepoRoot = dirname(absoluteCommonDir); const worktreeTop = execSync('git rev-parse --show-toplevel', { cwd: effectiveCwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, }).trim(); if (mainRepoRoot !== worktreeTop) { const sessionFile = basename(transcriptPath); if (sessionFile) { const configDir = getClaudeConfigDir(); const projectsDir = join(configDir, 'projects'); if (existsSync(projectsDir)) { const encodedMain = encodeProjectPath(mainRepoRoot); const resolvedPath = join(projectsDir, encodedMain, sessionFile); try { if (existsSync(resolvedPath)) return resolvedPath; } catch { /* fallthrough */ } } } } } catch { /* best-effort fallback */ } return transcriptPath; } // Simple JSON field extraction function extractJsonField(input, field, defaultValue = '') { try { const data = JSON.parse(input); return data[field] ?? defaultValue; } catch { // Fallback regex extraction const match = input.match(new RegExp(`"${field}"\\s*:\\s*"([^"]*)"`, 'i')); return match ? match[1] : defaultValue; } } // Get agent tracking info from state file function getAgentTrackingInfo(stateDir) { const trackingFile = join(stateDir, 'subagent-tracking.json'); try { if (existsSync(trackingFile)) { const data = JSON.parse(readFileSync(trackingFile, 'utf-8')); const running = (data.agents || []).filter(a => a.status === 'running').length; return { running, total: data.total_spawned || 0 }; } } catch {} return { running: 0, total: 0 }; } // Get todo status from project-local todos only async function getTodoStatus(directory) { let pending = 0; let inProgress = 0; // Check project-local todos const omcRoot = await resolveOmcStateRoot(directory); const localPaths = [ join(omcRoot, 'todos.json'), join(directory, '.claude', 'todos.json') ]; for (const todoFile of localPaths) { if (existsSync(todoFile)) { try { const content = readFileSync(todoFile, 'utf-8'); const data = JSON.parse(content); const todos = data.todos || data; if (Array.isArray(todos)) { pending += todos.filter(t => t.status === 'pending').length; inProgress += todos.filter(t => t.status === 'in_progress').length; } } catch { // Ignore errors } } } // NOTE: We intentionally do NOT scan the global // [$CLAUDE_CONFIG_DIR|~/.claude]/todos/ directory. // That directory accumulates todo files from ALL past sessions across all // projects, causing phantom task counts in fresh sessions (see issue #354). if (pending + inProgress > 0) { return `[${inProgress} active, ${pending} pending] `; } return ''; } function isValidSessionId(sessionId) { return typeof sessionId === 'string' && SESSION_ID_PATTERN.test(sessionId); } function readJsonFile(filePath) { try { if (!existsSync(filePath)) return null; return JSON.parse(readFileSync(filePath, 'utf-8')); } catch { return null; } } const STATE_STALE_MS = 2 * 60 * 60 * 1000; const ULTRAGOAL_TERMINAL_PHASES = new Set([ 'complete', 'completed', 'done', 'all-done', 'all_done', 'failed', 'cancelled', 'canceled', 'aborted', ]); function isStaleModeState(state) { if (!state || typeof state !== 'object') return true; const timestamps = [state.last_checked_at, state.updated_at, state.started_at] .filter(value => typeof value === 'string' && value.length > 0) .map(value => new Date(value).getTime()) .filter(value => Number.isFinite(value)); if (timestamps.length === 0) return true; return Date.now() - Math.max(...timestamps) > STATE_STALE_MS; } function normalizeText(value) { return typeof value === 'string' ? value.trim().replace(/\s+/g, ' ').toLowerCase() : ''; } function normalizePhase(value) { return typeof value === 'string' && value.trim().length > 0 ? value.trim().toLowerCase() : ''; } function isUltragoalTerminalState(state, directory) { if (!state || typeof state !== 'object') return true; if (state.active === false) return true; if (typeof state.completed_at === 'string' && state.completed_at.length > 0) return true; if (state.all_done === true || state.done === true) return true; const phase = normalizePhase(state.current_phase ?? state.phase ?? state.status); if (phase && ULTRAGOAL_TERMINAL_PHASES.has(phase)) return true; const plan = readJsonFile(join(directory, '.omc', 'ultragoal', 'goals.json')); if (!plan || typeof plan !== 'object') return false; if (plan.aggregateCompletion?.status === 'complete') return true; if (!Array.isArray(plan.goals) || plan.goals.length === 0) return false; return plan.goals.every(goal => { const status = normalizePhase(goal?.status); return status === 'complete' || status === 'review_blocked'; }); } function readSessionModeState(stateDir, mode, sessionId) { const filename = `${mode}-state.json`; const safeSessionId = isValidSessionId(sessionId) ? sessionId : ''; const candidates = safeSessionId ? [join(stateDir, 'sessions', safeSessionId, filename), join(stateDir, filename)] : [join(stateDir, filename)]; for (const statePath of candidates) { const state = readJsonFile(statePath); if (!state) continue; if (safeSessionId && state.session_id && state.session_id !== safeSessionId) continue; return { state, path: statePath }; } return { state: null, path: '' }; } function getExpectedUltragoalObjective(state, directory) { const candidates = [ state?.claude_goal_objective, state?.claudeGoalObjective, state?.codex_objective, state?.codexObjective, state?.goal_objective, state?.goalObjective, state?.objective, ]; for (const value of candidates) { if (typeof value === 'string' && value.trim()) return value.trim(); } const plan = readJsonFile(join(directory, '.omc', 'ultragoal', 'goals.json')); if (typeof plan?.claudeObjective === 'string' && plan.claudeObjective.trim()) return plan.claudeObjective.trim(); if (typeof plan?.aggregateCompletion?.objective === 'string' && plan.aggregateCompletion.objective.trim()) { return plan.aggregateCompletion.objective.trim(); } const activeGoal = Array.isArray(plan?.goals) ? plan.goals.find(goal => goal?.status === 'in_progress') : null; if (typeof activeGoal?.objective === 'string' && activeGoal.objective.trim()) return activeGoal.objective.trim(); return ''; } // Upper bound on the transcript we are willing to read on every tool call. const MAX_TRANSCRIPT_BYTES = 25 * 1024 * 1024; // The authoritative `/goal` signal is the slash-command *invocation* record, not the // `` display echo (which Claude Code also emits for ordinary // user-typed/pasted prompts, so display text is spoofable). A real record looks like // `/goal ... OBJECTIVE`. const GOAL_COMMAND_MARKER = /\s*\/goal\s*<\/command-name>/; const GOAL_COMMAND_ARGS = /([\s\S]*?)<\/command-args>/; // Any line that plausibly carries `/goal` state — intentionally broad (matches even // truncated markers) so a malformed goal-bearing record fails closed instead of // silently keeping a stale goal active. const GOAL_BEARING_HINT = /\/goal|Goal set|Goal cleared|local-command-stdout/; // Read a bounded regular file without following symlinks and without a stat->open race: // reject symlinks via lstat, then stat the open fd (fstat) and read through that same fd. function readBoundedTranscript(path) { let linkStat; try { linkStat = lstatSync(path); } catch { return null; } if (linkStat.isSymbolicLink() || !linkStat.isFile()) return null; let fd; try { fd = openSync(path, 'r'); } catch { return null; } try { const stat = fstatSync(fd); if (!stat.isFile() || stat.size === 0 || stat.size > MAX_TRANSCRIPT_BYTES) return null; const buffer = Buffer.allocUnsafe(stat.size); let read = 0; while (read < stat.size) { const n = readSync(fd, buffer, read, stat.size - read, read); if (n <= 0) break; read += n; } return buffer.toString('utf-8', 0, read); } catch { return null; } finally { try { closeSync(fd); } catch { /* ignore */ } } } // Recover the active Claude `/goal` from the session transcript. // // Claude Code does not expose live `/goal` state to hooks (the PreToolUse payload // carries none of the goal fields read below), but every hook receives // `transcript_path`, where a `/goal` invocation is recorded as a command record. // Reading it lets the guard observe a goal the user actually set instead of denying // forever (issue #3341) — without trusting arbitrary transcript text. // // Authorization boundary (PR review on #3465/#3466): // - resolve worktree-encoded paths via the shared resolver, then require the file to be // the active session's own transcript (`.jsonl`); // - read a bounded, non-symlink regular file through a stable fd; // - trust only command-originated `/goal` records (`/goal`), // never `` display text; // - apply set/clear strictly in order (last-event-wins) and fail closed if any // goal-bearing record is malformed. function extractGoalFromTranscript(rawTranscriptPath, sessionId, cwd) { if (typeof rawTranscriptPath !== 'string' || !rawTranscriptPath) return null; if (typeof sessionId !== 'string' || !sessionId) return null; const resolved = resolveTranscriptPath(rawTranscriptPath, cwd); if (typeof resolved !== 'string' || !resolved) return null; // Bind to the active session; Claude Code names the transcript `.jsonl`. if (basename(resolved).replace(/\.jsonl$/i, '') !== sessionId) return null; const content = readBoundedTranscript(resolved); if (content === null) return null; let objective = ''; for (const line of content.split('\n')) { if (!line || !GOAL_BEARING_HINT.test(line)) continue; let entry; try { entry = JSON.parse(line); } catch { return null; // fail closed: a malformed goal-bearing record invalidates recovery } if (entry?.type !== 'user') continue; const message = entry.message; if (!message || message.role !== 'user' || typeof message.content !== 'string') continue; // Trust only command-originated `/goal` records, not display stdout text. if (!GOAL_COMMAND_MARKER.test(message.content)) continue; const argsMatch = message.content.match(GOAL_COMMAND_ARGS); const args = argsMatch ? argsMatch[1].trim() : ''; // `/goal clear` (or empty args) clears; any other args are the objective. objective = args === '' || args.toLowerCase() === 'clear' ? '' : args; } if (!objective) return null; return { objective, status: 'active' }; } function extractClaudeGoalSnapshot(data, sessionId, cwd) { const candidates = [ data.goal, data.claude_goal, data.claudeGoal, data.goal_state, data.goalState, data.codex_goal, data.codexGoal, data.context?.goal, data.context?.claude_goal, ]; for (const candidate of candidates) { const goal = candidate?.goal && typeof candidate.goal === 'object' ? candidate.goal : candidate; if (goal && typeof goal === 'object') { const objective = goal.objective ?? goal.condition ?? goal.prompt ?? goal.description; const status = goal.status ?? goal.state; if (typeof objective === 'string' || typeof status === 'string') { return { objective: typeof objective === 'string' ? objective : '', status: typeof status === 'string' ? status : '' }; } } } // Runtime injected no goal field (the standard Claude Code case): fall back to the // transcript, the only place a hook can observe an active `/goal`. return extractGoalFromTranscript(data.transcript_path ?? data.transcriptPath, sessionId, cwd); } // A bootstrap/exit bypass must apply to one indivisible command only. Reject shell // chaining/expansion so a recognized token cannot smuggle other commands past the guard // (e.g. `omc ultragoal checkpoint ... && npm test`). See PR review on #3465. function isSingleShellCommand(command) { return typeof command === 'string' && command.trim().length > 0 && !/[\n\r;&|`]|\$\(|<\(|>\(/.test(command); } function isCancelSkillBootstrapTool(toolName, toolInput) { const skillName = extractSkillName(toolInput); if (toolName === 'Skill' && skillName === 'cancel') return true; if (toolName === 'ToolSearch') return true; if (toolName === 'Read') { const filePath = typeof toolInput.file_path === 'string' ? toolInput.file_path : typeof toolInput.path === 'string' ? toolInput.path : ''; const normalized = filePath.replace(/\\/g, '/'); if (/(?:^|\/)(?:skills|skill-bodies)\/cancel\/SKILL\.md$/i.test(normalized)) return true; } if (/state_(?:clear|read|write|list_active|get_status)$/i.test(toolName)) return true; if (/^mcp__.*__state_(?:clear|read|write|list_active|get_status)$/i.test(toolName)) return true; if (toolName !== 'Bash') return false; const command = typeof toolInput.command === 'string' ? toolInput.command : ''; if (!isSingleShellCommand(command)) return false; return /^(?:omc|oh-my-claudecode|gjc)\s+(?:state\s+(?:clear|read|write|list-active|get-status)|cancel)\b/.test(command.trim()); } function isUltragoalBootstrapTool(toolName, toolInput) { if (toolName === 'Skill' && extractSkillName(toolInput) === 'ultragoal') return true; if (toolName !== 'Bash') return false; const command = typeof toolInput.command === 'string' ? toolInput.command : ''; if (!isSingleShellCommand(command)) return false; return /^(?:omc|oh-my-claudecode)\s+ultragoal\s+(?:create(?:-goals)?|complete(?:-goals)?|next|start-next|status|checkpoint|record-review-blockers)\b/.test(command.trim()); } function evaluateUltragoalPreToolEnforcement(stateDir, directory, sessionId, data) { if (process.env.ALLOW_ULTRAGOAL_WITHOUT_GOAL === '1') return null; const toolName = data.tool_name || data.toolName || ''; const toolInput = data.toolInput || data.tool_input || {}; if (isUltragoalBootstrapTool(toolName, toolInput)) return null; if (isCancelSkillBootstrapTool(toolName, toolInput)) return null; const loaded = readSessionModeState(stateDir, 'ultragoal', sessionId); const state = loaded.state; if (!state?.active) return null; if (isStaleModeState(state)) return null; if (state.project_path && resolve(String(state.project_path)) !== resolve(directory)) return null; if (isUltragoalTerminalState(state, directory)) return null; const expected = getExpectedUltragoalObjective(state, directory); const actual = extractClaudeGoalSnapshot(data, sessionId, directory); const actualObjective = normalizeText(actual?.objective); const expectedObjective = normalizeText(expected); const status = normalizePhase(actual?.status); const objectiveMatches = Boolean(actualObjective && expectedObjective && actualObjective === expectedObjective); const activeStatus = status === '' || status === 'active' || status === 'in_progress' || status === 'running'; // #3341: Claude Code does not expose live `/goal` state to a PreToolUse hook, so // `actual` may be recovered from the session transcript (see extractGoalFromTranscript) // in addition to the payload. Objective/status matching semantics are otherwise // unchanged: an explicit or recovered goal must match the expected ultragoal objective // (or the expected objective must be unseeded). Denying when no active goal is // observable at all is preserved below, so the guard stays meaningful. if (!expectedObjective && actualObjective && activeStatus) return null; if (objectiveMatches && activeStatus) return null; const mismatch = actualObjective ? `current Claude /goal appears unrelated: "${actual.objective}".` : 'no active Claude /goal snapshot was visible to the hook.'; return `[ULTRAGOAL /GOAL REQUIRED] Active ultragoal state requires the matching Claude /goal before tools run; ${mismatch} Activate /goal with the ultragoal objective, or set ALLOW_ULTRAGOAL_WITHOUT_GOAL=1 to bypass this guard intentionally. Expected objective: ${expected || ''}`; } function hasActiveJsonMode(stateDir, { allowSessionTagged = false } = {}) { for (const file of MODE_STATE_FILES) { const state = readJsonFile(join(stateDir, file)); if (!state || state.active !== true) continue; if (!allowSessionTagged && state.session_id) continue; return true; } return false; } function hasActiveSwarmMode(stateDir, { allowSessionTagged = false } = {}) { const markerFile = join(stateDir, 'swarm-active.marker'); if (!existsSync(markerFile)) return false; const summary = readJsonFile(join(stateDir, 'swarm-summary.json')); if (!summary || summary.active !== true) return false; if (!allowSessionTagged && summary.session_id) return false; return true; } function hasActiveMode(stateDir, sessionId) { if (isValidSessionId(sessionId)) { const sessionStateDir = join(stateDir, 'sessions', sessionId); return ( hasActiveJsonMode(sessionStateDir, { allowSessionTagged: true }) || hasActiveSwarmMode(sessionStateDir, { allowSessionTagged: true }) ); } return ( hasActiveJsonMode(stateDir, { allowSessionTagged: false }) || hasActiveSwarmMode(stateDir, { allowSessionTagged: false }) ); } function mapCanonicalTeamPhaseToStage(rawPhase) { const phase = typeof rawPhase === 'string' ? rawPhase.trim().toLowerCase() : ''; switch (phase) { case 'initializing': case 'planning': return 'team-plan'; case 'executing': return 'team-exec'; case 'fixing': return 'team-fix'; case 'completed': return 'complete'; case 'failed': return 'failed'; default: return ''; } } function readCanonicalActiveTeamState(stateDir, sessionId) { if (!sessionId || !SESSION_ID_PATTERN.test(sessionId)) { return null; } const teamRoot = join(stateDir, 'team'); if (!existsSync(teamRoot)) { return null; } const entries = readdirSync(teamRoot, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort(); for (const teamName of entries) { const teamDir = join(teamRoot, teamName); const manifest = readJsonFile(join(teamDir, 'manifest.json')); const phaseState = readJsonFile(join(teamDir, 'phase-state.json')); const ownerSessionId = typeof manifest?.leader?.session_id === 'string' ? manifest.leader.session_id.trim() : ''; if (!ownerSessionId || ownerSessionId !== sessionId) { continue; } const stage = mapCanonicalTeamPhaseToStage(phaseState?.current_phase); if (!stage) { continue; } return { active: stage !== 'complete' && stage !== 'failed', session_id: sessionId, team_name: teamName, teamName, phase: stage, current_phase: stage, task: typeof manifest?.task === 'string' ? manifest.task : teamName, started_at: typeof manifest?.created_at === 'string' ? manifest.created_at : undefined, last_checked_at: typeof phaseState?.updated_at === 'string' ? phaseState.updated_at : undefined, }; } return null; } /** * Check if team mode is active for the given directory/session. * Reads team-state.json from session-scoped or legacy paths and falls back * to canonical team state when the coarse file drifts or disappears. */ function getActiveTeamState(stateDir, sessionId) { const paths = []; let coarseState = null; // Session-scoped path (preferred) if (sessionId && SESSION_ID_PATTERN.test(sessionId)) { paths.push(join(stateDir, 'sessions', sessionId, 'team-state.json')); } // Legacy path paths.push(join(stateDir, 'team-state.json')); for (const statePath of paths) { const state = readJsonFile(statePath); if (!state) { continue; } if (sessionId && state.session_id && state.session_id !== sessionId) { continue; } coarseState = state; if (state.active === true) { return state; } } const canonical = readCanonicalActiveTeamState(stateDir, sessionId); if (canonical && canonical.active === true) { return canonical; } return coarseState && coarseState.active === true ? coarseState : null; } // Generate agent spawn message with metadata function generateAgentSpawnMessage(toolInput, stateDir, todoStatus, sessionId) { if (!toolInput || typeof toolInput !== 'object') { if (QUIET_LEVEL >= 2) return ''; return `${todoStatus}Launch multiple agents in parallel when tasks are independent. Use run_in_background for long operations.`; } const agentType = toolInput.subagent_type || 'unknown'; const model = toolInput.model || 'inherit'; const desc = toolInput.description || ''; const bg = toolInput.run_in_background ? ' [BACKGROUND]' : ''; const tracking = getAgentTrackingInfo(stateDir); // Team-routing guidance: // Claude Code 2.1.178+ removed TeamCreate/TeamDelete. When OMC team state is // active, teammates should be spawned into the session's implicit agent team by // giving each Agent/Task call a distinct name. team_name is ignored by native // Claude Code and should only be treated as legacy metadata. const teamState = getActiveTeamState(stateDir, sessionId); if (teamState && !toolInput.name) { const teamName = teamState.team_name || teamState.teamName || 'team'; return `[TEAM ROUTING REQUIRED] Team "${teamName}" is active but you are spawning an unnamed subagent. ` + `Claude Code 2.1.178+ uses the session's implicit native agent team; TeamCreate and TeamDelete are removed. ` + `Spawn teammates directly with Agent/Task name="worker-N" and subagent_type="${agentType}". ` + `Do NOT rely on team_name for routing; native Claude Code accepts it only as ignored legacy metadata.`; } if (QUIET_LEVEL >= 2) return ''; const parts = [`${todoStatus}Spawning agent: ${agentType} (${model})${bg}`]; if (desc) parts.push(`Task: ${desc}`); if (tracking.running > 0) parts.push(`Active agents: ${tracking.running}`); return parts.join(' | '); } // Generate contextual message based on tool type function generateMessage(toolName, todoStatus, modeActive = false) { if (QUIET_LEVEL >= 1 && ['Bash', 'Edit', 'Write', 'Read', 'Grep', 'Glob'].includes(toolName)) { return ''; } if (QUIET_LEVEL >= 2 && toolName === 'TodoWrite') { return ''; } const messages = { TodoWrite: `${todoStatus}Mark todos in_progress BEFORE starting, completed IMMEDIATELY after finishing.`, Bash: `${todoStatus}Use parallel execution for independent tasks. Use run_in_background for long operations (npm install, builds, tests).`, Edit: `${todoStatus}Verify changes work after editing. Test functionality before marking complete.`, Write: `${todoStatus}Verify changes work after editing. Test functionality before marking complete.`, Read: `${todoStatus}Read multiple files in parallel when possible for faster analysis.`, Grep: `${todoStatus}Combine searches in parallel when investigating multiple patterns.`, Glob: `${todoStatus}Combine searches in parallel when investigating multiple patterns.`, }; if (messages[toolName]) return messages[toolName]; if (modeActive) return `${todoStatus}The boulder never stops. Continue until all tasks complete.`; return ''; } // --------------------------------------------------------------------------- // Skill Active State (issue #1033) // Writes skill-active-state.json so the persistent-mode Stop hook can prevent // premature session termination while a skill is executing. // --------------------------------------------------------------------------- const SKILL_PROTECTION_CONFIGS = { none: { maxReinforcements: 0, staleTtlMs: 0 }, light: { maxReinforcements: 3, staleTtlMs: 5 * 60 * 1000 }, medium: { maxReinforcements: 5, staleTtlMs: 15 * 60 * 1000 }, heavy: { maxReinforcements: 10, staleTtlMs: 30 * 60 * 1000 }, }; const SKILL_PROTECTION_MAP = { // === Already have mode state → no additional protection === autopilot: 'none', ralph: 'none', ultragoal: 'none', ultrawork: 'none', team: 'none', 'omc-teams': 'none', ultraqa: 'none', cancel: 'none', // === Instant / read-only → no protection needed === trace: 'none', hud: 'none', 'omc-doctor': 'none', 'omc-help': 'none', 'learn-about-omc': 'none', note: 'none', // === Light protection (simple shortcuts, 3 reinforcements) === skill: 'light', ask: 'light', 'configure-notifications': 'light', // === Medium protection (review/planning, 5 reinforcements) === 'omc-plan': 'medium', plan: 'medium', ralplan: 'none', // Has first-class checkRalplan() enforcement; no skill-active needed 'deep-interview': 'heavy', review: 'medium', 'external-context': 'medium', 'ai-slop-cleaner': 'medium', sciomc: 'medium', learner: 'medium', 'omc-setup': 'medium', setup: 'medium', // alias for omc-setup 'mcp-setup': 'medium', 'project-session-manager': 'medium', psm: 'medium', // alias for project-session-manager 'writer-memory': 'medium', 'ralph-init': 'medium', release: 'medium', ccg: 'medium', // === Heavy protection (long-running, 10 reinforcements) === deepinit: 'heavy', }; function getSkillProtectionLevel(skillName, rawSkillName) { // When rawSkillName is provided, only apply protection to OMC-prefixed skills. // Non-prefixed skills are project custom skills or other plugins — no protection. // See: https://github.com/Yeachan-Heo/oh-my-claudecode/issues/1581 if (rawSkillName != null && typeof rawSkillName === 'string' && !rawSkillName.toLowerCase().startsWith('oh-my-claudecode:')) { return 'none'; } const normalized = (skillName || '').toLowerCase().replace(/^oh-my-claudecode:/, ''); return SKILL_PROTECTION_MAP[normalized] || 'none'; } // Load OMC config to check forceInherit setting (issues #1135, #1201) function loadOmcConfig() { const configPaths = [ join(getClaudeConfigDir(), '.omc-config.json'), join(process.cwd(), '.omc', 'config.json'), ]; for (const configPath of configPaths) { try { if (existsSync(configPath)) { return JSON.parse(readFileSync(configPath, 'utf-8')); } } catch { /* continue */ } } return {}; } // Check if forceInherit is enabled via config or env var function isForceInheritEnabled() { if (process.env.OMC_ROUTING_FORCE_INHERIT === 'true') return true; const config = loadOmcConfig(); return config.routing?.forceInherit === true; } function extractSkillName(toolInput) { if (!toolInput || typeof toolInput !== 'object') return null; const rawSkill = toolInput.skill || toolInput.skill_name || toolInput.skillName || toolInput.command || null; if (typeof rawSkill !== 'string' || !rawSkill.trim()) return null; const normalized = rawSkill.trim(); return normalized.includes(':') ? normalized.split(':').at(-1).toLowerCase() : normalized.toLowerCase(); } function writeSkillActiveState(stateDir, skillName, sessionId, rawSkillName) { const protection = getSkillProtectionLevel(skillName, rawSkillName); if (protection === 'none') return; const config = SKILL_PROTECTION_CONFIGS[protection]; const now = new Date().toISOString(); const normalized = (skillName || '').toLowerCase().replace(/^oh-my-claudecode:/, ''); const safeSessionId = sessionId && SESSION_ID_PATTERN.test(sessionId) ? sessionId : ''; const targetDir = safeSessionId ? join(stateDir, 'sessions', safeSessionId) : stateDir; const targetPath = join(targetDir, 'skill-active-state.json'); // Nesting guard: when a skill (e.g. omc-setup) invokes a child skill // (e.g. mcp-setup), the child must not overwrite the parent's active state. // If a DIFFERENT skill is already active in this session, skip writing — // the parent's stop-hook protection already covers the session. // If the SAME skill is re-invoked, allow the overwrite (idempotent refresh). // // NOTE: This read-check-write sequence has a TOCTOU race condition // (non-atomic), but this is acceptable because Claude Code sessions are // single-threaded — only one tool call executes at a time within a session. try { if (existsSync(targetPath)) { const existing = JSON.parse(readFileSync(targetPath, 'utf-8')); if (existing.active && existing.skill_name && existing.skill_name !== normalized) { return; // A different skill already owns the active state — do not overwrite. } } } catch { // If read/parse fails, treat as no existing state — proceed with write } const state = { active: true, skill_name: normalized, session_id: safeSessionId || undefined, started_at: now, last_checked_at: now, reinforcement_count: 0, max_reinforcements: config.maxReinforcements, stale_ttl_ms: config.staleTtlMs, }; try { if (!existsSync(targetDir)) { mkdirSync(targetDir, { recursive: true }); } const tmpPath = targetPath + '.tmp'; const envelope = { ...state, _meta: { written_at: now, mode: 'skill-active', ...(safeSessionId ? { sessionId: safeSessionId } : {}) }, }; writeFileSync(tmpPath, JSON.stringify(envelope, null, 2), { mode: 0o600 }); renameSync(tmpPath, targetPath); } catch { // Best-effort; don't fail the hook } } function clearAwaitingConfirmationFlag(stateDir, stateName, sessionId) { const safeSessionId = sessionId && SESSION_ID_PATTERN.test(sessionId) ? sessionId : ''; const paths = [ safeSessionId ? join(stateDir, 'sessions', safeSessionId, `${stateName}-state.json`) : null, join(stateDir, `${stateName}-state.json`), ].filter(Boolean); for (const statePath of paths) { try { if (!existsSync(statePath)) continue; const state = JSON.parse(readFileSync(statePath, 'utf-8')); if (!state || typeof state !== 'object' || !state.awaiting_confirmation) continue; delete state.awaiting_confirmation; const tmpPath = statePath + '.tmp'; writeFileSync(tmpPath, JSON.stringify(state, null, 2), { mode: 0o600 }); renameSync(tmpPath, statePath); } catch { // Best-effort; don't fail the hook } } } function confirmSkillModeStates(stateDir, skillName, sessionId) { switch (skillName) { case 'ralph': clearAwaitingConfirmationFlag(stateDir, 'ralph', sessionId); clearAwaitingConfirmationFlag(stateDir, 'ultrawork', sessionId); break; case 'ultragoal': clearAwaitingConfirmationFlag(stateDir, 'ultragoal', sessionId); break; case 'ultrawork': clearAwaitingConfirmationFlag(stateDir, 'ultrawork', sessionId); break; case 'autopilot': clearAwaitingConfirmationFlag(stateDir, 'autopilot', sessionId); break; case 'ralplan': clearAwaitingConfirmationFlag(stateDir, 'ralplan', sessionId); break; default: break; } } // Record Skill/Task invocations to flow trace (best-effort) async function recordToolInvocation(data, directory) { try { const toolName = data.toolName || data.tool_name || ''; const sessionId = data.session_id || data.sessionId || ''; if (!sessionId || !directory) return; if (toolName === 'Skill') { const skillName = data.toolInput?.skill || data.tool_input?.skill || ''; if (skillName) { const { recordSkillInvoked } = await import('../dist/hooks/subagent-tracker/flow-tracer.js'); recordSkillInvoked(directory, sessionId, skillName); } } } catch { /* best-effort, never block tool execution */ } } async function main() { // Skip guard: check OMC_SKIP_HOOKS env var (see issue #838) const _skipHooks = (process.env.OMC_SKIP_HOOKS || '').split(',').map(s => s.trim()); if (process.env.DISABLE_OMC === '1' || _skipHooks.includes('pre-tool-use')) { console.log(JSON.stringify({ continue: true })); return; } try { const input = await readStdin(); const toolName = extractJsonField(input, 'tool_name') || extractJsonField(input, 'toolName', 'unknown'); const directory = extractJsonField(input, 'cwd') || extractJsonField(input, 'directory', process.cwd()); // Resolve the .omc state root once, honoring OMC_STATE_DIR. // All helpers receive stateDir so they stay in sync with the centralized // resolver used by session-start.mjs and persistent-mode (issue #2518, PR #2532). const omcRoot = await resolveOmcStateRoot(directory); const stateDir = join(omcRoot, 'state'); // Record Skill invocations to flow trace let data = {}; try { data = JSON.parse(input); } catch {} recordToolInvocation(data, directory); // Activate skill state when Skill tool is invoked (issue #1033) // Writes skill-active-state.json so the persistent-mode Stop hook can // prevent premature session termination while a skill is executing. if (toolName === 'Skill') { const toolInput = data.toolInput || data.tool_input || {}; const skillName = extractSkillName(toolInput); if (skillName) { const sid = typeof data.session_id === 'string' ? data.session_id : typeof data.sessionId === 'string' ? data.sessionId : ''; // Pass rawSkillName to distinguish OMC skills from project custom skills (issue #1581) const rawSkill = toolInput.skill || toolInput.skill_name || toolInput.skillName || toolInput.command || ''; const rawSkillName = typeof rawSkill === 'string' && rawSkill.trim() ? rawSkill.trim() : undefined; writeSkillActiveState(stateDir, skillName, sid, rawSkillName); confirmSkillModeStates(stateDir, skillName, sid); } } const sessionId = typeof data.session_id === 'string' ? data.session_id : typeof data.sessionId === 'string' ? data.sessionId : ''; const ultragoalDenyReason = evaluateUltragoalPreToolEnforcement(stateDir, directory, sessionId, data); if (ultragoalDenyReason) { console.log(JSON.stringify({ continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: ultragoalDenyReason } })); return; } const modeActive = hasActiveMode(stateDir, sessionId); // When set, replaces the Task/Agent tool input via hookSpecificOutput.updatedInput // so a configured per-agent model (agents..model) is applied (issue #3242). let updatedToolInput = null; // Force-inherit check: deny Task/Agent calls with invalid model param when forceInherit is // enabled (Bedrock, Vertex, CC Switch, etc.) - issues #1135, #1201, #1767, #1868 // // New behaviour (issue #1868 — [1m] suffix deadlock): // ALLOW explicit valid provider-specific model IDs (full Bedrock/Vertex format, no [1m]) // DENY tier names (sonnet/opus/haiku) and [1m]-suffixed IDs // DENY no-model calls when the session model itself has [1m] — guide to OMC_SUBAGENT_MODEL if (toolName === 'Task' || toolName === 'Agent') { const toolInput = data.toolInput || data.tool_input || {}; const toolModel = toolInput.model; if (isForceInheritEnabled()) { // Check both vars: if either carries [1m] the session model is unsafe for sub-agents. // Avoids a split-brain between the hook and runtime code that may read the vars in // different orders (e.g. model-contract.ts uses ANTHROPIC_MODEL first). const claudeModel = process.env.CLAUDE_MODEL || ''; const anthropicModel = process.env.ANTHROPIC_MODEL || ''; const sessionHasLmSuffix = hasExtendedContextSuffix(claudeModel) || hasExtendedContextSuffix(anthropicModel); // For error messages: prefer whichever var actually carries the [1m] suffix. const sessionModel = hasExtendedContextSuffix(claudeModel) ? claudeModel : hasExtendedContextSuffix(anthropicModel) ? anthropicModel : claudeModel || anthropicModel; if (toolModel) { // Allow tier aliases (sonnet/opus/haiku) when a subagent-safe model can be // resolved for that tier. Resolution chain: OMC_SUBAGENT_MODEL (global override) // → CLAUDE_CODE_BEDROCK_*_MODEL → ANTHROPIC_DEFAULT_*_MODEL. if (isTierAlias(toolModel) && resolveTierAliasToSafeModel(toolModel)) { // fall through to continue — tier alias resolves to a safe provider-specific ID } else if (!isSubagentSafeModelId(toolModel)) { const tierUpper = isTierAlias(toolModel) ? toolModel.toUpperCase() : ''; const derivedTier = tierUpper || (normalizeToCcAlias(toolModel) || '').toUpperCase(); const guidance = derivedTier ? `Set ANTHROPIC_DEFAULT_${derivedTier}_MODEL= in settings.json env, or set OMC_SUBAGENT_MODEL as a global override.` : `Remove the \`model\` parameter, or set ANTHROPIC_DEFAULT_SONNET_MODEL= in settings.json env.`; console.log(JSON.stringify({ continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `[MODEL ROUTING] This environment uses a non-standard provider (Bedrock/Vertex/proxy). ${guidance} The model "${toolModel}" is not valid for this provider.` } })); return; } // else: valid provider-specific model ID — fall through to continue. } else if (sessionHasLmSuffix) { // No model param, but the session model has a [1m] context-window suffix. // Sub-agents would inherit it and fail — the runtime strips [1m] to a bare // Anthropic model ID (e.g. claude-sonnet-5) which is invalid on Bedrock. // Fix: pass a tier alias (sonnet/haiku/opus). The Agent tool schema only accepts // tier aliases for the model param — full Bedrock IDs are rejected by the schema. const tierAlias = normalizeToCcAlias(sessionModel) || 'sonnet'; const resolvedSafe = resolveTierAliasToSafeModel(tierAlias); const suggestion = resolvedSafe ? `Pass model="${tierAlias}" explicitly on this ${toolName} call — tier aliases resolve cleanly on Bedrock.` : `Pass model="${tierAlias}" explicitly on this ${toolName} call, and set ANTHROPIC_DEFAULT_${tierAlias.toUpperCase()}_MODEL= in settings.json env.`; console.log(JSON.stringify({ continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `[MODEL ROUTING] Your session model "${sessionModel}" has a context-window suffix ([1m]) that sub-agents cannot inherit — the runtime strips it to a bare Anthropic model ID which is invalid on Bedrock. ${suggestion}` } })); return; } // Agent-definition model check: runs for any no-model call with a subagent_type, // independent of the sessionHasLmSuffix branch above (which may have matched and // fallen through safely). Claude Code reads the agent definition's `model:` field // AFTER this hook and injects it — if that's a bare Anthropic ID, Bedrock rejects // with 400. Detect it here and deny with guidance to retry with an explicit tier alias. if (!toolModel && toolInput.subagent_type) { const agentDefModel = readAgentDefinitionModel(toolInput.subagent_type); // Only deny when a safe routing target exists for the derived tier alias. // Without a routing target the tier-alias escape hatch doesn't exist, so blocking // would strand Claude in a retry loop with no viable path forward. const defTierAlias = agentDefModel ? normalizeToCcAlias(agentDefModel) : null; const resolvedModel = defTierAlias ? resolveTierAliasToSafeModel(defTierAlias) : ''; const hasSafeRouting = !!resolvedModel; if (agentDefModel && !isSubagentSafeModelId(agentDefModel) && !isTierAlias(agentDefModel) && hasSafeRouting) { const guidance = `Add model="${defTierAlias}" to this ${toolName} call — tier aliases resolve to configured provider models (${resolvedModel}).`; const agentType = (toolInput.subagent_type).replace(/^oh-my-claudecode:/, ''); console.log(JSON.stringify({ continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: `[MODEL ROUTING] Agent type "${agentType}" has model "${agentDefModel}" in its definition, which is not valid for this Bedrock/Vertex/proxy environment. ${guidance}` } })); return; } } // else: no model param and no [1m] on session model → normal forceInherit, // agents inherit the parent session's model cleanly. } else if (!toolModel && toolInput.subagent_type) { // Non-forceInherit: honor agents..model from config.jsonc for native // Task/Agent calls without an explicit model param. Without this, Claude Code // reads the static agents/*.md frontmatter and silently ignores the user's // per-agent override (issue #3242). Inject the resolved tier alias via // updatedInput so the spawned subagent runs on the configured model. const configuredModel = resolveConfiguredAgentModel(toolInput.subagent_type, directory); if (configuredModel && configuredModel !== 'inherit') { const normalizedModel = normalizeToCcAlias(configuredModel); if (normalizedModel) { updatedToolInput = { ...toolInput, model: normalizedModel }; } } } } // Send notification when AskUserQuestion is about to execute (user input needed) // Fires in PreToolUse so users get notified BEFORE the tool blocks for input (#597) if (toolName === 'AskUserQuestion') { try { const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT; if (pluginRoot) { const { notify } = await import(pathToFileURL(join(pluginRoot, 'dist', 'notifications', 'index.js')).href); const toolInput = data.toolInput || data.tool_input || {}; const questions = toolInput.questions || []; const questionText = questions.map(q => q.question || '').filter(Boolean).join('; ') || 'User input requested'; const sessionId = data.session_id || data.sessionId || ''; // Fire and forget - don't block tool execution notify('ask-user-question', { sessionId, projectPath: directory, question: questionText, }).catch(() => {}); } } catch { // Notification not available, skip } } const todoStatus = await getTodoStatus(directory); // Force-agent-delegation: symmetric to evaluateAgentHeavyPreflight. Where // preflight blocks Task/Agent spawning when context is exhausted, this // evaluator blocks raw Read/Edit/Write/Grep/Glob when configured rules // indicate the work should be delegated to a specialised agent. Default OFF // — only fires when `.omc/config.json` has `routing.forceDelegation.enforce`. const delegationBlock = evaluateForceAgentDelegation({ toolName, stateDir, loadOmcConfig, }); if (delegationBlock) { // Force-delegation preflight returns `{ decision: 'block', reason }` to // match the agent-heavy preflight contract. Translate to the // Claude Code hookSpecificOutput shape (`permissionDecision: 'deny'`). console.log(JSON.stringify({ continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: delegationBlock.reason, }, })); return; } if (toolName === 'Task' || toolName === 'Agent') { const rawTranscriptPath = data.transcript_path || data.transcriptPath || ''; const transcriptPath = resolveTranscriptPath(rawTranscriptPath, directory); const preflightBlock = evaluateAgentHeavyPreflight({ toolName, transcriptPath, }); if (preflightBlock) { console.log(JSON.stringify(preflightBlock)); return; } } const slopWarning = generateSlopWarning(data, toolName); let message; if (BUILT_IN_TASK_LIST_TOOL_NAMES.has(toolName)) { console.log(JSON.stringify({ continue: true, suppressOutput: true })); return; } if (toolName === 'Task' || toolName === 'Agent') { const toolInput = data.toolInput || data.tool_input || null; // Reflect any injected per-agent model (issue #3242) in the advisory label. message = generateAgentSpawnMessage(updatedToolInput || toolInput, stateDir, todoStatus, sessionId); } else { message = generateMessage(toolName, todoStatus, modeActive); } message = combineHookMessages(slopWarning, message); // Carry any per-agent model injection (issue #3242) even when the advisory // message is empty or throttled, so the configured model is always applied. const modelInjection = updatedToolInput ? { hookSpecificOutput: { hookEventName: 'PreToolUse', updatedInput: updatedToolInput } } : null; if (!message) { console.log(JSON.stringify( modelInjection ? { continue: true, suppressOutput: true, ...modelInjection } : { continue: true, suppressOutput: true } )); return; } if (!shouldEmitAdvisoryMessage(stateDir, sessionId, message)) { console.log(JSON.stringify( modelInjection ? { continue: true, suppressOutput: true, ...modelInjection } : { continue: true, suppressOutput: true } )); return; } console.log(JSON.stringify({ continue: true, hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: message, ...(updatedToolInput ? { updatedInput: updatedToolInput } : {}) } }, null, 2)); } catch (error) { // On error, always continue console.log(JSON.stringify({ continue: true, suppressOutput: true })); } } main();