mirror of
https://github.com/Yeachan-Heo/oh-my-claudecode.git
synced 2026-09-03 06:25:33 +08:00
1976 lines
83 KiB
JavaScript
1976 lines
83 KiB
JavaScript
#!/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 { execFileSync } 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, resolveSessionStatePathsForHook } from './lib/state-root.mjs';
|
|
import { readStdin } from './lib/stdin.mjs';
|
|
import { resolveConfiguredAgentModel } from './lib/agent-model-config.mjs';
|
|
import { BOUNDED_GIT_TIMEOUT_MS } from './lib/bounded-git-timeout.mjs';
|
|
import { isSkillVisibleToUser } from './lib/skill-entitlements.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;
|
|
}
|
|
}
|
|
// ---------------------------------------------------------------------------
|
|
// Skill vs agent namespace guard (issue #3667)
|
|
//
|
|
// Task/Agent subagent_type identifiers and bundled skills share the same
|
|
// `oh-my-claudecode:` namespace, so a caller can hand a skill name to
|
|
// Task(subagent_type=...) and receive only Claude Code's generic native
|
|
// "Agent type not found". OMC owns both registries (agents/*.md and
|
|
// skills/*/SKILL.md), so the PreToolUse hook denies the call BEFORE the
|
|
// native boundary with an error that names the Skill tool and the exact
|
|
// identifier, and forbids closest-match substitution.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SKILL_AGENT_NAMESPACE_PREFIXES = ['oh-my-claudecode:', 'omc:'];
|
|
const SKILL_IDENTIFIER_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
|
|
function splitAgentNamespace(subagentType) {
|
|
const folded = subagentType.toLowerCase();
|
|
for (const prefix of SKILL_AGENT_NAMESPACE_PREFIXES) {
|
|
if (folded.startsWith(prefix.toLowerCase())) {
|
|
return { name: subagentType.slice(prefix.length), namespaced: true };
|
|
}
|
|
}
|
|
return { name: subagentType, namespaced: false };
|
|
}
|
|
|
|
function getPluginAgentDirs() {
|
|
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
|
const scriptAgentsDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'agents');
|
|
return pluginRoot ? [join(pluginRoot, 'agents'), scriptAgentsDir] : [scriptAgentsDir];
|
|
}
|
|
|
|
function getPluginSkillsDirs() {
|
|
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
|
|
const scriptSkillsDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
|
|
return pluginRoot ? [join(pluginRoot, 'skills'), scriptSkillsDir] : [scriptSkillsDir];
|
|
}
|
|
|
|
/**
|
|
* Whether an agent definition resolves for the given identifier.
|
|
* Namespaced identifiers (oh-my-claudecode:X / omc:X) resolve only against
|
|
* plugin agents; bare identifiers resolve through the full native chain
|
|
* (plugin, project .claude/agents, user config agents). A real agent always
|
|
* wins over a bundled skill with the same name (collision rule).
|
|
*/
|
|
function agentDefinitionExists(agentType, directory, namespaced) {
|
|
const agentDirs = getPluginAgentDirs();
|
|
if (!namespaced) {
|
|
agentDirs.push(join(directory, '.claude', 'agents'));
|
|
agentDirs.push(join(getClaudeConfigDir(), 'agents'));
|
|
}
|
|
return agentDirs.some((agentsDir) => existsSync(join(agentsDir, `${agentType}.md`)));
|
|
}
|
|
|
|
/**
|
|
* Extract the primary `name` and raw alias list from a bundled SKILL.md YAML
|
|
* frontmatter block. Mirrors readAgentDefinitionModel's frontmatter
|
|
* extraction: only the first `--- ... ---` block is inspected, so `name:`
|
|
* lines in the skill body cannot create false matches.
|
|
*/
|
|
function parseSkillFrontmatterIdentifiers(content) {
|
|
const fmMatch = content.match(/^---[\r\n]+([\s\S]*?)[\r\n]+---/);
|
|
if (!fmMatch) return { aliases: [], primary: null };
|
|
const fm = fmMatch[1];
|
|
const nameMatch = fm.match(/^name:\s*(\S+)/m);
|
|
const primary = nameMatch ? nameMatch[1].trim().replace(/^["']|["']$/g, '') : null;
|
|
const aliasMatch = fm.match(/^aliases:\s*(.+)$/m);
|
|
const aliases = [];
|
|
if (aliasMatch) {
|
|
const raw = aliasMatch[1].trim();
|
|
const tokens = raw.startsWith('[')
|
|
? raw.slice(1, raw.indexOf(']') === -1 ? raw.length : raw.indexOf(']')).split(',')
|
|
: [raw.split(/\s+/)[0]];
|
|
for (const token of tokens) {
|
|
const clean = token.trim().replace(/^["']|["']$/g, '');
|
|
if (clean) aliases.push(clean);
|
|
}
|
|
}
|
|
return { aliases, primary };
|
|
}
|
|
|
|
/**
|
|
* Claude Code native command names that must not be shadowed by OMC skill
|
|
* short names. Mirrors src/features/builtin-skills/skills.ts:CC_NATIVE_COMMANDS
|
|
* and toSafeSkillName (plan -> omc-plan).
|
|
*/
|
|
const CC_NATIVE_SKILL_COMMANDS = new Set([
|
|
'review',
|
|
'plan',
|
|
'security-review',
|
|
'init',
|
|
'doctor',
|
|
'help',
|
|
'config',
|
|
'clear',
|
|
'compact',
|
|
'memory',
|
|
]);
|
|
|
|
function toSafeSkillName(name) {
|
|
const normalized = name.trim();
|
|
return CC_NATIVE_SKILL_COMMANDS.has(normalized.toLowerCase()) ? `omc-${normalized}` : normalized;
|
|
}
|
|
/**
|
|
* Whether a bundled skill directory is visible to the current user, mirroring
|
|
* loadSkillsFromDirectory's entitlement filter. Hidden skills must never be
|
|
* suggested as invocable, even when their directory exists on disk.
|
|
*/
|
|
let cachedCanonicalSkillRegistry = null;
|
|
|
|
/**
|
|
* Build the canonical bundled-skill registry exactly like the runtime loader
|
|
* (src/features/builtin-skills/skills.ts loadSkillsFromDirectory +
|
|
* loadSkillFromFile):
|
|
* - `skillify` sorts first so it claims its deprecated alias `learner` before
|
|
* the legacy skills/learner directory is seen;
|
|
* - primary names and aliases are normalized with toSafeSkillName;
|
|
* - the first claim of a name wins (seenNames dedup), so a directory whose
|
|
* name is claimed as another skill's alias is not registered under its own
|
|
* name.
|
|
* Returns Map<lowercaseName, primaryName>.
|
|
*/
|
|
function buildCanonicalSkillRegistry() {
|
|
if (cachedCanonicalSkillRegistry) return cachedCanonicalSkillRegistry;
|
|
const registry = new Map();
|
|
for (const skillsDir of getPluginSkillsDirs()) {
|
|
let entries = [];
|
|
try {
|
|
entries = readdirSync(skillsDir, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
entries.sort((a, b) => {
|
|
if (a.name === 'skillify') return -1;
|
|
if (b.name === 'skillify') return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
// Entitlement filter: hidden skills are not registered for this user.
|
|
if (!isSkillVisibleToUser(entry.name)) continue;
|
|
const skillPath = join(skillsDir, entry.name, 'SKILL.md');
|
|
if (!existsSync(skillPath)) continue;
|
|
let parsed;
|
|
try {
|
|
parsed = parseSkillFrontmatterIdentifiers(readFileSync(skillPath, 'utf-8'));
|
|
} catch {
|
|
continue;
|
|
}
|
|
const primary = toSafeSkillName(parsed.primary || entry.name);
|
|
const allNames = [primary, ...parsed.aliases.map(toSafeSkillName)];
|
|
for (const candidate of allNames) {
|
|
const key = candidate.toLowerCase();
|
|
if (registry.has(key)) continue;
|
|
registry.set(key, primary);
|
|
}
|
|
}
|
|
}
|
|
cachedCanonicalSkillRegistry = registry;
|
|
return registry;
|
|
}
|
|
|
|
/**
|
|
* Resolve a Task/Agent subagent_type against the bundled skill registry.
|
|
* Returns { primary } when the identifier names a bundled skill (exact match
|
|
* only — no fuzzy/closest-match substitution), or null otherwise.
|
|
*
|
|
* Canonical registry precedence wins before any directory shortcut: a name
|
|
* claimed as a deprecated alias (e.g. `learner` owned by `skillify`) resolves
|
|
* to its canonical primary even when a directory with the same name exists
|
|
* (skills/learner).
|
|
*
|
|
* Bare (un-namespaced) identifiers are denied ONLY on canonical registry
|
|
* claims. The directory shortcut would otherwise mistake legitimate runtime
|
|
* agents for skills: Claude Code's built-in `Plan` agent and session-defined
|
|
* agents are not visible to file-based plugin/project/user agent discovery,
|
|
* yet `skills/plan` exists (registering `omc-plan`). Bare names therefore
|
|
* never consult the directory shortcut; explicitly namespaced identifiers
|
|
* (`oh-my-claudecode:` / `omc:`) are pinned to the OMC plugin namespace and
|
|
* keep the full canonical + shortcut resolution (e.g. `oh-my-claudecode:plan`
|
|
* -> `omc-plan`).
|
|
*/
|
|
function resolveBundledSkill(subagentType, directory) {
|
|
const { name, namespaced } = splitAgentNamespace(subagentType);
|
|
if (!SKILL_IDENTIFIER_PATTERN.test(name)) return null;
|
|
// Case-fold once, before every check: registry, alias, visibility, and
|
|
// filesystem lookups must agree even on case-insensitive filesystems
|
|
// (Windows/macOS), where a case-variant identifier resolves the same dir.
|
|
const foldedName = name.toLowerCase();
|
|
// A real agent definition wins over a skill with the same name.
|
|
if (agentDefinitionExists(foldedName, directory, namespaced)) return null;
|
|
// Canonical registry first: covers primaries and aliases (incl. collisions
|
|
// like learner -> skillify, cancel-ralph -> cancel).
|
|
const canonicalPrimary = buildCanonicalSkillRegistry().get(foldedName);
|
|
if (canonicalPrimary) return { primary: canonicalPrimary };
|
|
// Bare identifiers stop here: the directory shortcut is reserved for the
|
|
// pinned plugin namespace so native/session-defined agents (e.g. `Plan`)
|
|
// are never denied (issue #3667 P1).
|
|
if (!namespaced) return null;
|
|
// Directory shortcut fallback only for names the canonical registry does not
|
|
// claim (e.g. the plan/ dir whose frontmatter registers as omc-plan).
|
|
// Fail closed: a directory that is hidden from this user must never be
|
|
// suggested as an invocable bundled skill, even though it exists on disk.
|
|
if (!isSkillVisibleToUser(foldedName)) return null;
|
|
for (const skillsDir of getPluginSkillsDirs()) {
|
|
const directPath = join(skillsDir, foldedName, 'SKILL.md');
|
|
if (existsSync(directPath)) {
|
|
let primary = foldedName;
|
|
try {
|
|
const parsed = parseSkillFrontmatterIdentifiers(readFileSync(directPath, 'utf-8'));
|
|
if (parsed.primary) primary = parsed.primary;
|
|
} catch {
|
|
// Keep the directory name when the file cannot be parsed.
|
|
}
|
|
return { primary: toSafeSkillName(primary) };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Preflight contract for #3667: when a Task/Agent call names a bundled skill
|
|
* as its subagent_type, deny the call with a precise, non-substitutable error
|
|
* that names the Skill tool and the correct identifier.
|
|
*/
|
|
function evaluateSkillAsAgentCall(toolName, toolInput, directory) {
|
|
if (!toolInput || typeof toolInput !== 'object') return null;
|
|
const rawSubagentType = toolInput.subagent_type;
|
|
if (typeof rawSubagentType !== 'string') return null;
|
|
const subagentType = rawSubagentType.trim();
|
|
if (subagentType.length === 0) return null;
|
|
|
|
const skill = resolveBundledSkill(subagentType, directory);
|
|
if (!skill) return null;
|
|
|
|
const { name } = splitAgentNamespace(subagentType);
|
|
// Always suggest the canonical plugin-namespaced identifier. A bare skill
|
|
// name can resolve to a different project/user skill or fail: bundled
|
|
// skills are exposed under the `oh-my-claudecode:` namespace (issue #3667
|
|
// review), so the recovery must be unambiguous regardless of the caller's
|
|
// input namespace form.
|
|
const skillIdentifier = `oh-my-claudecode:${skill.primary}`;
|
|
const isPrimaryMatch = name.toLowerCase() === skill.primary.toLowerCase();
|
|
const queriedName = isPrimaryMatch
|
|
? `"${subagentType}"`
|
|
: `"${subagentType}" (alias of "${skill.primary}")`;
|
|
const reason =
|
|
`[SKILL vs AGENT] ${queriedName} is a Skill, not an agent. ` +
|
|
`Do NOT call it via ${toolName}(subagent_type=...) — that subagent type does not exist, ` +
|
|
`and Claude Code will fail the call with a generic "Agent type not found". ` +
|
|
`Use the Skill tool instead: Skill(skill="${skillIdentifier}"). ` +
|
|
`Do NOT substitute a similarly-named agent as a "closest match".`;
|
|
return {
|
|
continue: true,
|
|
hookSpecificOutput: {
|
|
hookEventName: 'PreToolUse',
|
|
permissionDecision: 'deny',
|
|
permissionDecisionReason: reason,
|
|
},
|
|
};
|
|
}
|
|
|
|
|
|
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',
|
|
'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 = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
cwd: dir,
|
|
encoding: 'utf-8',
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
timeout: BOUNDED_GIT_TIMEOUT_MS,
|
|
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 = execFileSync('git', ['rev-parse', '--git-common-dir'], {
|
|
cwd: effectiveCwd,
|
|
encoding: 'utf-8',
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
timeout: BOUNDED_GIT_TIMEOUT_MS,
|
|
windowsHide: true,
|
|
}).trim();
|
|
|
|
const absoluteCommonDir = resolve(effectiveCwd, gitCommonDir);
|
|
const mainRepoRoot = dirname(absoluteCommonDir);
|
|
|
|
const worktreeTop = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
cwd: effectiveCwd,
|
|
encoding: 'utf-8',
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
timeout: BOUNDED_GIT_TIMEOUT_MS,
|
|
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.
|
|
// Path resolution is owned by the canonical resolveSessionStatePathsForHook()
|
|
// helper (validation/migration-aware, session-scoped-first read with legacy
|
|
// fallback), so this reader cannot drift from the other OmC consumers again.
|
|
// Issue #3732: the pre-fix manual path construction read legacy-only while
|
|
// post-tool-verifier::getAgentCompletionSummary read session-scoped first,
|
|
// producing contradicting agent counts for the same session.
|
|
//
|
|
// Name note: the canonical resolver normalizes `subagent-tracking` to
|
|
// `<stateDir>/sessions/<sid>/subagent-tracking-state.json` (Wave-A layout) with
|
|
// read fallback to `<stateDir>/subagent-tracking-state.json`. When the
|
|
// session-scoped file exists but is malformed, the canonical legacy file for
|
|
// the same name is probed explicitly (never skipped). The pre-Wave-A legacy
|
|
// file was plain `subagent-tracking.json` (still read by
|
|
// session-end/post-tool-verifier and written by pre-Wave-A installs), so after
|
|
// the canonical probe finds nothing the plain legacy filename is read
|
|
// directly. When no sessionId is observable only the two legacy filenames are
|
|
// probed.
|
|
async function getAgentTrackingInfo(stateDir, directory, sessionId = '') {
|
|
// The session id arrives from the hook payload and is influenceable, so it
|
|
// is validated against the canonical allowlist BEFORE any scoped resolution.
|
|
// An invalid id must skip every session-scoped candidate and only probe the
|
|
// safe legacy roots: the canonical resolver would throw on it, and the
|
|
// unvalidated path would let a payload like `../evil` escape the sessions
|
|
// directory and read unrelated state (issue #3732 review).
|
|
const safeSessionId = isValidSessionId(sessionId) ? sessionId : '';
|
|
const candidates = [];
|
|
try {
|
|
// Session-scoped effective read for the canonical state name (scoped-first
|
|
// with canonical-legacy read fallback).
|
|
const { readPath } = await resolveSessionStatePathsForHook(directory, 'subagent-tracking', safeSessionId || undefined);
|
|
candidates.push(readPath);
|
|
// Explicit canonical legacy read: when the session-scoped file exists but
|
|
// is malformed the effective read above points at it, and without this
|
|
// probe the valid <stateDir>/subagent-tracking-state.json would be
|
|
// silently skipped. The canonical legacy file is probed even when no
|
|
// session id is observable (issue #3732 review). Both reads stay owned by
|
|
// the canonical resolver — never the suffixed `subagent-tracking.json`
|
|
// state name, which the resolver would corrupt into `-state.json`.
|
|
const legacy = await resolveSessionStatePathsForHook(directory, 'subagent-tracking', undefined);
|
|
if (legacy.readPath !== readPath) candidates.push(legacy.readPath);
|
|
} catch {}
|
|
// Pre-Wave-A legacy filename, read directly (outside the canonical naming
|
|
// scheme) so old installs keep working. resolveSessionStatePathsForHook
|
|
// appends `-state.json`, which would corrupt a name that already ends in
|
|
// `.json`, hence the direct join here under the resolved state root.
|
|
candidates.push(join(stateDir, 'subagent-tracking.json'));
|
|
|
|
for (const trackingFile of candidates) {
|
|
const data = readJsonFile(trackingFile);
|
|
// Shape-validate per candidate: a parseable file with a non-array `agents`
|
|
// field is a malformed candidate, never a reason to abort the whole hook
|
|
// (which would drop the spawn advisory and any configured model injection).
|
|
// Skip it and continue with the next canonical/legacy candidate.
|
|
if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.agents)) {
|
|
continue;
|
|
}
|
|
const running = data.agents.filter(a => a && typeof a === 'object' && a.status === 'running').length;
|
|
return { running, total: data.total_spawned || 0 };
|
|
}
|
|
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',
|
|
]);
|
|
|
|
const AWAITING_CONFIRMATION_TTL_MS = 2 * 60 * 1000;
|
|
|
|
function isAwaitingConfirmation(state) {
|
|
if (!state || state.awaiting_confirmation !== true) return false;
|
|
|
|
const preferred = state.awaiting_confirmation_set_at;
|
|
const timestamp = typeof preferred === 'string' && preferred.trim()
|
|
? preferred
|
|
: typeof state.started_at === 'string' && state.started_at.trim()
|
|
? state.started_at
|
|
: null;
|
|
if (!timestamp) return false;
|
|
|
|
const timestampMs = new Date(timestamp).getTime();
|
|
const ageMs = Date.now() - timestampMs;
|
|
return Number.isFinite(ageMs) && ageMs >= 0 && ageMs < AWAITING_CONFIRMATION_TTL_MS;
|
|
}
|
|
|
|
|
|
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
|
|
// `<local-command-stdout>` display echo (which Claude Code also emits for ordinary
|
|
// user-typed/pasted prompts, so display text is spoofable). A real record looks like
|
|
// `<command-name>/goal</command-name> ... <command-args>OBJECTIVE</command-args>`.
|
|
const GOAL_COMMAND_MARKER = /<command-name>\s*\/goal\s*<\/command-name>/;
|
|
const GOAL_COMMAND_ARGS = /<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 (`<sessionId>.jsonl`);
|
|
// - read a bounded, non-symlink regular file through a stable fd;
|
|
// - trust only command-originated `/goal` records (`<command-name>/goal</command-name>`),
|
|
// never `<local-command-stdout>` 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 `<sessionId>.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;
|
|
// Bare CLI names (PATH installs) or trusted plugin entrypoint via node/nodejs.
|
|
// Basename is constrained to oh-my-claudecode.js; arbitrary node scripts stay denied.
|
|
// isSingleShellCommand above rejects chaining/expansion so a recognized token cannot
|
|
// smuggle other commands past the guard (e.g. `... cancel && npm test`).
|
|
return /^(?:(?:node|nodejs)\s+(?:"[^"\n]*[/\\]oh-my-claudecode\.js"|'[^'\n]*[/\\]oh-my-claudecode\.js'|[^\s;|&`]*[/\\]oh-my-claudecode\.js)\s+|(?: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;
|
|
if (isAwaitingConfirmation(state)) 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 || '<record one in ultragoal-state.json or .omc/ultragoal/goals.json>'}`;
|
|
}
|
|
|
|
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
|
|
async function generateAgentSpawnMessage(toolInput, stateDir, directory, 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 = await getAgentTrackingInfo(stateDir, directory, sessionId);
|
|
|
|
// 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 ===
|
|
'omc-teams': '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',
|
|
|
|
// === 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);
|
|
break;
|
|
case 'ultragoal':
|
|
clearAwaitingConfirmationFlag(stateDir, 'ultragoal', 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.<name>.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 || {};
|
|
// Skill vs agent namespace guard (issue #3667): deny BEFORE the native
|
|
// boundary when a bundled skill name is passed as subagent_type, with an
|
|
// error that names the Skill tool and the correct identifier.
|
|
const skillAgentDeny = evaluateSkillAsAgentCall(toolName, toolInput, directory);
|
|
if (skillAgentDeny) {
|
|
console.log(JSON.stringify(skillAgentDeny));
|
|
return;
|
|
}
|
|
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=<valid-bedrock-id> in settings.json env, or set OMC_SUBAGENT_MODEL as a global override.`
|
|
: `Remove the \`model\` parameter, or set ANTHROPIC_DEFAULT_SONNET_MODEL=<valid-bedrock-id> 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=<valid-bedrock-id> 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.<name>.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 = await generateAgentSpawnMessage(updatedToolInput || toolInput, stateDir, directory, 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();
|