import { buildDocumentRefreshAdvisoryOutput, evaluateStagedDocumentRefresh, } from "../document-refresh/enforcer.js"; import { resolveCodexExecutionSurface } from "./codex-execution-surface.js"; type CodexHookPayload = Record; type GitRepositorySelection = "current-cwd" | "explicit-target"; export interface NormalizedPreToolUsePayload { toolName: string; toolUseId: string; command: string; normalizedCommand: string; isBash: boolean; } export interface NormalizedPostToolUsePayload { toolName: string; toolUseId: string; command: string; normalizedCommand: string; isBash: boolean; rawToolResponse: unknown; parsedToolResponse: Record | null; exitCode: number | null; stdoutText: string; stderrText: string; } export interface McpTransportFailureSignal { toolName: string; summary: string; } function safeString(value: unknown): string { return typeof value === "string" ? value : ""; } function safeInteger(value: unknown): number | null { if (typeof value === "number" && Number.isInteger(value)) return value; if (typeof value === "string" && value.trim() !== "") { const parsed = Number.parseInt(value.trim(), 10); if (Number.isInteger(parsed)) return parsed; } return null; } function safeObject(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null; } function isNativeOutsideTmuxSurface(payload: CodexHookPayload): boolean { const cwd = safeString(payload.cwd).trim() || process.cwd(); const surface = resolveCodexExecutionSurface(cwd, { hookEventName: "PreToolUse", payload, nativeSessionId: safeString(payload.session_id ?? payload.sessionId).trim(), }); return surface.launcher === "native" && surface.transport === "outside-tmux"; } function tryParseJsonString(value: unknown): Record | null { const text = safeString(value).trim(); if (!text) return null; try { return safeObject(JSON.parse(text)); } catch { return null; } } function readCommand(payload: CodexHookPayload): string { const toolInput = safeObject(payload.tool_input); return safeString(toolInput?.command).trim(); } export function normalizePreToolUsePayload( payload: CodexHookPayload, ): NormalizedPreToolUsePayload { const toolName = safeString(payload.tool_name).trim(); const command = readCommand(payload); return { toolName, toolUseId: safeString(payload.tool_use_id).trim(), command, normalizedCommand: command, isBash: toolName === "Bash", }; } export function normalizePostToolUsePayload( payload: CodexHookPayload, ): NormalizedPostToolUsePayload { const toolName = safeString(payload.tool_name).trim(); const command = readCommand(payload); const rawToolResponse = payload.tool_response; const parsedToolResponse = tryParseJsonString(rawToolResponse) ?? safeObject(rawToolResponse); const exitCode = safeInteger(parsedToolResponse?.exit_code) ?? safeInteger(parsedToolResponse?.exitCode) ?? null; const rawText = safeString(rawToolResponse).trim(); const stdoutText = safeString(parsedToolResponse?.stdout).trim() || rawText; const stderrText = safeString(parsedToolResponse?.stderr).trim(); return { toolName, toolUseId: safeString(payload.tool_use_id).trim(), command, normalizedCommand: command, isBash: toolName === "Bash", rawToolResponse, parsedToolResponse, exitCode, stdoutText, stderrText, }; } function matchesDestructiveFixture(command: string): boolean { return /^\s*rm\s+-rf\s+dist(?:\s|$)/.test(command); } function isMcpLikeToolName(toolName: string): boolean { return /^(mcp__|rcs_(?:state|memory|trace|code_intel)\b|state_|project_memory_|notepad_|trace_)/i.test(toolName); } const MCP_TRANSPORT_FAILURE_PATTERNS = [ /transport (?:closed|error|failed)/i, /server disconnected/i, /connection (?:closed|reset|lost)/i, /\beconnreset\b/i, /\bepipe\b/i, /broken pipe/i, /stream ended unexpectedly/i, /stdio .*closed/i, /pipe closed/i, /mcp(?: server)? .*closed/i, ]; type RcsParityCommand = | "state" | "notepad" | "project-memory" | "trace" | "code-intel"; export function detectMcpTransportFailure( payload: CodexHookPayload, ): McpTransportFailureSignal | null { const normalized = normalizePostToolUsePayload(payload); if (normalized.isBash) return null; const combined = [ normalized.stderrText, normalized.stdoutText, safeString(normalized.parsedToolResponse?.error), safeString(normalized.parsedToolResponse?.message), safeString(normalized.parsedToolResponse?.details), ] .filter(Boolean) .join("\n") .trim(); const mcpContextDetected = isMcpLikeToolName(normalized.toolName) || /\bmcp\b/i.test(combined) || /\brcs-(?:state|memory|trace|code-intel)-server\b/i.test(combined); if (!mcpContextDetected) return null; if (!combined) return null; if (!MCP_TRANSPORT_FAILURE_PATTERNS.some((pattern) => pattern.test(combined))) { return null; } return { toolName: normalized.toolName, summary: combined, }; } function resolveRcsParityTarget(toolName: string): { command: RcsParityCommand; tool: string } | null { const match = toolName.match(/^mcp__rcs_(state|memory|trace|code_intel)__([a-z0-9_]+)$/i); if (!match) return null; const [, server, tool] = match; if (server === "state") return { command: "state", tool }; if (server === "trace") return { command: "trace", tool }; if (server === "code_intel") return { command: "code-intel", tool }; if (server === "memory" && tool.startsWith("notepad_")) { return { command: "notepad", tool }; } if (server === "memory" && tool.startsWith("project_memory_")) { return { command: "project-memory", tool }; } return null; } function shellSingleQuote(value: string): string { return `'${value.replace(/'/g, `'\"'\"'`)}'`; } function buildRcsParityFallbackCommand(payload: CodexHookPayload, toolName: string): string | null { const target = resolveRcsParityTarget(toolName); if (!target) return null; const input = safeObject(payload.tool_input) ?? {}; return `rcs ${target.command} ${target.tool} --input ${shellSingleQuote(JSON.stringify(input))} --json`; } const LORE_TRAILER_PREFIXES = [ "Constraint:", "Rejected:", "Confidence:", "Scope-risk:", "Reversibility:", "Directive:", "Tested:", "Not-tested:", "Related:", ] as const; const RCS_COAUTHOR_TRAILER = "Co-authored-by: RCS "; function isDoubleQuotedShellEscapeTarget(char: string | undefined): boolean { return char === "\"" || char === "\\" || char === "$" || char === "`" || char === "\n"; } function tokenizeShellCommand(commandText: string): string[] | null { const trimmed = commandText.trim(); if (!trimmed) return null; const tokens: string[] = []; let current = ""; let quote: "'" | "\"" | null = null; let escaping = false; for (let index = 0; index < trimmed.length; index += 1) { const char = trimmed[index] ?? ""; if (escaping) { current += char; escaping = false; continue; } if (quote === "'") { if (char === "'") quote = null; else current += char; continue; } if (quote === "\"") { if (char === "\"") quote = null; else if (char === "\\") { if (isDoubleQuotedShellEscapeTarget(trimmed[index + 1])) escaping = true; else current += char; } else current += char; continue; } if (/\s/.test(char)) { if (current) { tokens.push(current); current = ""; } continue; } if (char === "'" || char === "\"") { quote = char; continue; } if (char === "\\") { escaping = true; continue; } current += char; } if (escaping || quote) return null; if (current) tokens.push(current); return tokens.length > 0 ? tokens : null; } interface GitCommitCommandParseResult { isGitCommit: boolean; inlineMessage: string | null; repositorySelection: GitRepositorySelection; requiresExternalMessageSource: boolean; } function isInlineShellEnvAssignment(token: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*=.*$/u.test(token); } function isGitExecutableToken(token: string): boolean { const lowerToken = token.toLowerCase(); if (lowerToken === "git" || lowerToken === "git.exe") return true; const normalized = token.replaceAll("\\", "/"); const segments = normalized.split("/"); const basename = (segments[segments.length - 1] ?? "").toLowerCase(); return basename === "git" || basename === "git.exe"; } function isEnvExecutableToken(token: string): boolean { const lowerToken = token.toLowerCase(); if (lowerToken === "env") return true; const normalized = token.replaceAll("\\", "/"); const segments = normalized.split("/"); const basename = (segments[segments.length - 1] ?? "").toLowerCase(); return basename === "env"; } function envOptionConsumesNextValue(token: string): boolean { return token === "-u" || token === "--unset" || token === "-C" || token === "--chdir" || token === "-S" || token === "--split-string"; } function findGitCommandTokenIndex(tokens: string[]): number { let index = 0; while (index < tokens.length && isInlineShellEnvAssignment(tokens[index] ?? "")) { index += 1; } while (index < tokens.length && isEnvExecutableToken(tokens[index] ?? "")) { index += 1; while (index < tokens.length) { const token = tokens[index] ?? ""; if (token === "--") { index += 1; break; } if (isInlineShellEnvAssignment(token) || token.startsWith("-")) { if (envOptionConsumesNextValue(token)) { index += 1; } index += 1; continue; } break; } while (index < tokens.length && isInlineShellEnvAssignment(tokens[index] ?? "")) { index += 1; } } return index < tokens.length && isGitExecutableToken(tokens[index] ?? "") ? index : -1; } function gitOptionConsumesNextValue(token: string): boolean { return token === "-c" || token === "-C" || token === "--git-dir" || token === "--work-tree" || token === "--namespace" || token === "--super-prefix" || token === "--exec-path" || token === "--config-env" || token === "--attr-source"; } function gitOptionSelectsRepository(token: string): boolean { return token === "-C" || token === "--git-dir" || token === "--work-tree" || token.startsWith("--git-dir=") || token.startsWith("--work-tree="); } function gitOptionStopsBeforeSubcommand(token: string): boolean { return token === "-h" || token === "--help" || token === "--version" || token === "--html-path" || token === "--man-path" || token === "--info-path"; } function findGitSubcommandIndex(tokens: string[], gitTokenIndex: number): number { let index = gitTokenIndex + 1; while (index < tokens.length) { const token = tokens[index] ?? ""; if (!token) { index += 1; continue; } if (token === "--") { index += 1; break; } if (!token.startsWith("-")) break; if (gitOptionStopsBeforeSubcommand(token)) return -1; if (gitOptionConsumesNextValue(token)) { index += 2; continue; } index += 1; } return index < tokens.length ? index : -1; } function readGitRepositorySelection(tokens: string[], gitTokenIndex: number, subcommandIndex: number): GitRepositorySelection { for (let index = gitTokenIndex + 1; index < subcommandIndex; index += 1) { const token = tokens[index] ?? ""; if (gitOptionSelectsRepository(token)) return "explicit-target"; if (gitOptionConsumesNextValue(token)) index += 1; } return "current-cwd"; } export function parseGitCommitCommand(commandText: string): GitCommitCommandParseResult { const tokens = tokenizeShellCommand(commandText); if (!tokens) { return { isGitCommit: false, inlineMessage: null, repositorySelection: "current-cwd", requiresExternalMessageSource: false, }; } const gitTokenIndex = findGitCommandTokenIndex(tokens); if (gitTokenIndex < 0 || !isGitExecutableToken(tokens[gitTokenIndex] ?? "")) { return { isGitCommit: false, inlineMessage: null, repositorySelection: "current-cwd", requiresExternalMessageSource: false, }; } const subcommandIndex = findGitSubcommandIndex(tokens, gitTokenIndex); if (subcommandIndex < 0 || tokens[subcommandIndex]?.toLowerCase() !== "commit") { return { isGitCommit: false, inlineMessage: null, repositorySelection: "current-cwd", requiresExternalMessageSource: false, }; } const repositorySelection = readGitRepositorySelection(tokens, gitTokenIndex, subcommandIndex); const messageParts: string[] = []; let requiresExternalMessageSource = false; const args = tokens.slice(subcommandIndex + 1); for (let index = 0; index < args.length; index += 1) { const token = args[index] ?? ""; if (token === "-m" || token === "--message") { const nextValue = args[index + 1]; if (typeof nextValue === "string") { messageParts.push(nextValue); index += 1; } continue; } if (token.startsWith("--message=")) { messageParts.push(token.slice("--message=".length)); continue; } if ( token === "-F" || token === "--file" || token === "-c" || token === "-C" || token === "--reuse-message" || token === "--reedit-message" || token === "--fixup" || token.startsWith("--fixup=") || token === "--squash" || token.startsWith("--squash=") || token === "--template" || token === "-t" || token.startsWith("--file=") || token.startsWith("--reuse-message=") || token.startsWith("--reedit-message=") || token.startsWith("--template=") ) { requiresExternalMessageSource = true; } } return { isGitCommit: true, inlineMessage: messageParts.length > 0 ? messageParts.join("\n\n").trim() : null, repositorySelection, requiresExternalMessageSource, }; } function isLoreTrailerLine(line: string): boolean { return line === RCS_COAUTHOR_TRAILER || LORE_TRAILER_PREFIXES.some((prefix) => line.startsWith(prefix)); } function splitParagraphs(text: string): string[] { return text .split(/\n\s*\n/) .map((paragraph) => paragraph.trim()) .filter(Boolean); } function splitBodyAndTrailerLines(text: string): { bodyText: string; trailerLines: string[]; } { const paragraphs = splitParagraphs(text); let trailerStart = paragraphs.length; while (trailerStart > 0) { const paragraph = paragraphs[trailerStart - 1] ?? ""; const lines = paragraph .split("\n") .map((line) => line.trim()) .filter(Boolean); if (lines.length === 0 || !lines.every((line) => isLoreTrailerLine(line))) break; trailerStart -= 1; } return { bodyText: paragraphs.slice(0, trailerStart).join("\n\n").trim(), trailerLines: paragraphs .slice(trailerStart) .flatMap((paragraph) => paragraph.split("\n")) .map((line) => line.trim()) .filter(Boolean), }; } function buildGitCommitComplianceErrors(message: string | null): string[] { if (!message) { return [ "Provide the commit message inline with `git commit -m ...` so the pre-tool-use hook can validate Lore format before the command runs.", ]; } const normalized = message.replace(/\r\n?/g, "\n").trim(); if (!normalized) { return [ "Provide a non-empty Lore-format commit message with an intent-first subject, narrative body, Lore trailers, and the RCS co-author trailer.", ]; } const lines = normalized.split("\n"); const errors: string[] = []; if (lines[0]?.trim() === "") { errors.push("Start the commit message with a non-empty intent-first subject line."); } if (lines.length < 2 || lines[1]?.trim() !== "") { errors.push("Add a blank line after the subject before the narrative body."); } const { bodyText, trailerLines } = splitBodyAndTrailerLines(lines.slice(2).join("\n")); if (!bodyText) { errors.push("Add a narrative body paragraph explaining the decision context."); } if (!trailerLines.some((line) => LORE_TRAILER_PREFIXES.some((prefix) => line.startsWith(prefix)))) { errors.push("Add at least one Lore trailer such as `Constraint:`, `Confidence:`, or `Tested:`."); } if (!trailerLines.includes(RCS_COAUTHOR_TRAILER)) { errors.push(`Add the required co-author trailer: \`${RCS_COAUTHOR_TRAILER}\`.`); } return errors; } function buildGitCommitEnforcementOutput(commandText: string): Record | null { const parsed = parseGitCommitCommand(commandText); if (!parsed.isGitCommit) return null; const errors = parsed.requiresExternalMessageSource ? [ "Use inline `git commit -m ...` paragraphs for Lore-format commits in this path; file/editor/reuse/fixup message sources are not inspectable safely from pre-tool-use enforcement.", ] : buildGitCommitComplianceErrors(parsed.inlineMessage); if (errors.length === 0) return null; return { decision: "block", reason: "git commit is blocked until the inline commit message satisfies the Lore format and includes the required RCS co-author trailer.", hookSpecificOutput: { hookEventName: "PreToolUse", }, systemMessage: [ "git commit is blocked until the inline commit message follows the Lore protocol and includes `Co-authored-by: RCS `.", ...errors.map((error) => `- ${error}`), ].join("\n"), }; } function buildDocumentRefreshPreToolUseOutput( commandText: string, cwd: string, ): Record | null { const parsed = parseGitCommitCommand(commandText); if (!parsed.isGitCommit) return null; if (parsed.repositorySelection !== "current-cwd") return null; const warning = evaluateStagedDocumentRefresh(cwd, parsed.inlineMessage); if (!warning) return null; return buildDocumentRefreshAdvisoryOutput(warning, "PreToolUse"); } const SLOPPY_FALLBACK_PHRASE_PATTERNS = [ /\bquick hack\b/i, /\bhacky\b/i, /\bworkaround for now\b/i, /\btemporary workaround\b/i, /\btemporary fallback\b/i, /\bjust bypass\b/i, /\bjust skip\b/i, /\bskip (?:the )?(?:failing )?(?:test|validation|checks?)\b/i, /\bfallback if (?:it|this|that) fails\b/i, /\bfor now,? just\b/i, /\bbypass (?:the )?(?:failing )?(?:test|validation|checks?)\b/i, ] as const; const SLOPPY_FALLBACK_IMPLEMENTATION_CONTEXT_PATTERNS = [ /\badd\b/i, /\bimplement\b/i, /\bpatch\b/i, /\bwrite\b/i, /\bchange\b/i, /\bfix\b/i, /\bbypass\b/i, /\bfallback\b/i, /\bworkaround\b/i, /\bskip\b/i, /\bdisable\b/i, ] as const; const SLOPPY_FALLBACK_GROUNDING_PATTERNS = [ /\btested\b/i, /\btests? pass(?:ed)?\b/i, /\bnpm (?:run )?test\b/i, /\bnode --test\b/i, /\bunit tests?\b/i, /\bintegration tests?\b/i, /\bregression tests?\b/i, /\bcoverage\b/i, /\bspec(?:ification)?\b/i, /\bADR\b/, /\barchitecture\b/i, /\barchitect\b/i, /\bdesign\b/i, /\bbecause\b/i, /\bcompatib(?:le|ility)\b/i, /\bbackward-compatible\b/i, /\bfail-safe\b/i, /\bfailsafe\b/i, /\benvironment issue\b/i, /\benv(?:ironment)? problem\b/i, /\buser approved\b/i, /\bapproved by (?:the )?user\b/i, /(?:^|\s)#\d+\b/, /\bPR\s*#?\d+\b/i, ] as const; const READ_ONLY_COMMAND_TOKENS = new Set([ "cat", "find", "grep", "head", "less", "ls", "rg", "sed", "tail", ]); function commandStartsWithReadOnlyInspection(command: string): boolean { if (commandHasWriteLikeIntent(command)) return false; const tokens = tokenizeShellCommand(command); if (!tokens || tokens.length === 0) return false; let commandToken = tokens[0] ?? ""; if (commandToken === "env") { const nextCommand = tokens.find((token, index) => index > 0 && !token.startsWith("-") && !isInlineShellEnvAssignment(token)); commandToken = nextCommand ?? commandToken; } const basename = commandToken.replace(/\\/g, "/").split("/").pop()?.toLowerCase() ?? commandToken.toLowerCase(); if (!READ_ONLY_COMMAND_TOKENS.has(basename)) return false; if (basename === "sed" && tokens.some((token) => token === "-i" || token.startsWith("-i"))) return false; if (basename === "cat" && /(?:^|[;&|]\s*)cat\b[\s\S]{0,200}>\s*[^\s&|;]+/.test(command)) return false; return !/\|\s*(?:sh|bash|zsh|python3?|node|perl|ruby|apply_patch)\b/i.test(command); } function commandHasWriteLikeIntent(command: string): boolean { return /\bapply_patch\b/.test(command) || /(?:^|[;&|]\s*)(?:cat|printf|echo)\b[\s\S]{0,200}>\s*[^\s&|;]+/.test(command) || /\btee\s+(?:-a\s+)?[^\s&|;]+/.test(command) || /\bsed\s+(?:[^\n;&|]*\s)?-i(?:\b|['"])/.test(command) || /\b(?:python3?|node|perl|ruby)\b[\s\S]{0,240}\b(?:writeFileSync|writeFile|write_text|open\([^)]*["']w|File\.write|Path\()/.test(command) || /<<['"]?[A-Za-z0-9_ -]+['"]?[\s\S]*(?:^|\n)(?:\+\+\+\s|---\s|import\s|export\s|function\s|const\s|class\s|interface\s)/m.test(command); } function hasAnyPattern(text: string, patterns: readonly RegExp[]): boolean { return patterns.some((pattern) => pattern.test(text)); } function detectSloppyFallbackFraming(command: string): boolean { const trimmed = command.trim(); if (!trimmed) return false; if (commandStartsWithReadOnlyInspection(trimmed)) return false; if (!commandHasWriteLikeIntent(trimmed)) return false; if (!hasAnyPattern(trimmed, SLOPPY_FALLBACK_PHRASE_PATTERNS)) return false; if (!hasAnyPattern(trimmed, SLOPPY_FALLBACK_IMPLEMENTATION_CONTEXT_PATTERNS)) return false; if (hasAnyPattern(trimmed, SLOPPY_FALLBACK_GROUNDING_PATTERNS)) return false; return true; } function buildSloppyFallbackPreToolUseOutput(commandText: string): Record | null { if (!detectSloppyFallbackFraming(commandText)) return null; return { hookSpecificOutput: { hookEventName: "PreToolUse", }, systemMessage: "Sloppy fallback/workaround framing detected: don't make potential slop. Consult an architect for a concrete architecture, or ask the user if this is an environment issue before adding bypass/fallback code.", }; } function commandInvokesRcsQuestion(command: string): boolean { const tokens = tokenizeShellCommand(command)?.map((token) => token.toLowerCase()) ?? []; for (let index = 0; index < tokens.length; index += 1) { const rawToken = tokens[index] || ''; const token = rawToken.replace(/\\/g, '/').split('/').pop() || ''; if ((token === 'rcs' || token === 'rcs.js') && tokens[index + 1] === 'question') return true; if ((token === 'node' || token === 'node.exe') && /(?:^|\/)rcs\.js$/.test(tokens[index + 1] || '') && tokens[index + 2] === 'question') return true; } return /\brcs\s+question\b/i.test(command) || /\brcs\.js['"]?\s+question\b/i.test(command); } function isQuestionReturnPaneAssignment(token: string): boolean { const equalsIndex = token.indexOf('='); if (equalsIndex <= 0) return false; const name = token.slice(0, equalsIndex); if (!['RCS_QUESTION_RETURN_PANE', 'RCS_LEADER_PANE_ID', 'TMUX_PANE'].includes(name)) return false; const value = token.slice(equalsIndex + 1); return /^%\d+$/.test(value) || /^\$\{?TMUX_PANE\}?$/.test(value); } function hasInheritedQuestionReturnPaneBridge(): boolean { // Intentionally trust only the explicit bridge envs that question renderer // already accepts outside tmux; TMUX_PANE alone is not stable across all // Bash/background-terminal tool paths that this enforcement protects. const explicitPane = safeString( process.env.RCS_QUESTION_RETURN_PANE || process.env.RCS_LEADER_PANE_ID, ).trim(); return /^%\d+$/.test(explicitPane); } function commandHasPowerShellQuestionReturnPane(command: string): boolean { return /\$env:(?:RCS_QUESTION_RETURN_PANE|RCS_LEADER_PANE_ID)\s*=\s*(?:['"]?%\d+['"]?|\$env:TMUX_PANE)\b/i.test(command) || /\$env:TMUX_PANE\s*=\s*['"]?%\d+['"]?/i.test(command); } function commandHasQuestionReturnPane(command: string): boolean { if (hasInheritedQuestionReturnPaneBridge()) return true; if (commandHasPowerShellQuestionReturnPane(command)) return true; return (tokenizeShellCommand(command) ?? []).some(isQuestionReturnPaneAssignment); } function commandInvokesRcsTeam(command: string): boolean { const tokens = tokenizeShellCommand(command)?.map((token) => token.toLowerCase()) ?? []; for (let index = 0; index < tokens.length; index += 1) { const rawToken = tokens[index] || ''; const token = rawToken.replace(/\\/g, '/').split('/').pop() || ''; if ((token === 'rcs' || token === 'rcs.js') && tokens[index + 1] === 'team') return true; if ((token === 'node' || token === 'node.exe') && /(?:^|\/)rcs\.js$/.test(tokens[index + 1] || '') && tokens[index + 2] === 'team') return true; } return /\brcs\s+team\b/i.test(command) || /\brcs\.js['"]?\s+team\b/i.test(command); } function commandInvokesRcsHud(command: string): boolean { const tokens = tokenizeShellCommand(command)?.map((token) => token.toLowerCase()) ?? []; for (let index = 0; index < tokens.length; index += 1) { const rawToken = tokens[index] || ''; const token = rawToken.replace(/\\/g, '/').split('/').pop() || ''; if ((token === 'rcs' || token === 'rcs.js') && tokens[index + 1] === 'hud') return true; if ((token === 'node' || token === 'node.exe') && /(?:^|\/)rcs\.js$/.test(tokens[index + 1] || '') && tokens[index + 2] === 'hud') return true; } return /\brcs\s+hud\b/i.test(command) || /\brcs\.js['"]?\s+hud\b/i.test(command); } function buildNativeRcsHudPreToolUseEnforcementOutput( command: string, payload: CodexHookPayload, ): Record | null { if (!isNativeOutsideTmuxSurface(payload) || !commandInvokesRcsHud(command)) return null; return { decision: "block", reason: "rcs hud cannot be launched directly from Codex App/native outside-tmux Bash sessions.", systemMessage: "rcs hud is blocked from Bash in Codex App/native outside-tmux sessions; use SessionStart/HUD context instead, or launch RCS CLI from an attached tmux shell first for the tmux HUD runtime.", }; } function buildNativeRcsTeamPreToolUseEnforcementOutput( command: string, payload: CodexHookPayload, ): Record | null { if (!isNativeOutsideTmuxSurface(payload) || !commandInvokesRcsTeam(command)) return null; return { decision: "block", reason: "rcs team cannot be launched directly from Codex App/native outside-tmux Bash sessions.", systemMessage: `rcs team is blocked from Bash in Codex App/native outside-tmux sessions; launch RCS CLI from an attached tmux shell first. Original command: ${command}`, }; } function buildRcsQuestionPreToolUseEnforcementOutput( command: string, payload: CodexHookPayload, ): Record | null { if (!commandInvokesRcsQuestion(command)) return null; if (isNativeOutsideTmuxSurface(payload)) { return { decision: "block", reason: "rcs question cannot be launched directly from Codex App/native outside-tmux Bash sessions.", systemMessage: `rcs question is blocked from Codex App/native outside-tmux Bash because no attached tmux pane is available. Use the native structured question tool when available, or ask exactly one concise plain-text question. Original command: ${command}`, }; } if (commandHasQuestionReturnPane(command)) return null; return { decision: "block", reason: "rcs question Bash invocations must preserve the leader pane return target.", systemMessage: `rcs question is blocked from Bash until the command preserves the leader pane with \`RCS_QUESTION_RETURN_PANE=$TMUX_PANE\` or an explicit \`%pane\` value. Original command: ${command}`, }; } export function buildNativePreToolUseOutput( payload: CodexHookPayload, ): Record | null { const normalized = normalizePreToolUsePayload(payload); if (!normalized.isBash) return null; const gitCommitEnforcement = buildGitCommitEnforcementOutput(normalized.normalizedCommand); if (gitCommitEnforcement) return gitCommitEnforcement; const hudEnforcement = buildNativeRcsHudPreToolUseEnforcementOutput(normalized.normalizedCommand, payload); if (hudEnforcement) return hudEnforcement; const teamEnforcement = buildNativeRcsTeamPreToolUseEnforcementOutput(normalized.normalizedCommand, payload); if (teamEnforcement) return teamEnforcement; const questionEnforcement = buildRcsQuestionPreToolUseEnforcementOutput(normalized.normalizedCommand, payload); if (questionEnforcement) return questionEnforcement; const documentRefreshWarning = buildDocumentRefreshPreToolUseOutput( normalized.normalizedCommand, safeString(payload.cwd).trim() || process.cwd(), ); if (documentRefreshWarning) return documentRefreshWarning; const sloppyFallbackWarning = buildSloppyFallbackPreToolUseOutput(normalized.normalizedCommand); if (sloppyFallbackWarning) return sloppyFallbackWarning; if (!matchesDestructiveFixture(normalized.normalizedCommand)) return null; return { hookSpecificOutput: { hookEventName: "PreToolUse", }, systemMessage: "Destructive Bash command detected (`rm -rf dist`). Confirm the target and expected side effects before running it.", }; } function containsHardFailure(text: string): boolean { return /command not found|permission denied|no such file or directory/i.test(text); } function hasActionableBashHardFailure(normalized: NormalizedPostToolUsePayload): boolean { if (containsHardFailure(normalized.stderrText)) return true; if (normalized.exitCode === null || normalized.exitCode === 0) return false; return containsHardFailure(`${normalized.stderrText}\n${normalized.stdoutText}`); } export function buildNativePostToolUseOutput( payload: CodexHookPayload, ): Record | null { const normalized = normalizePostToolUsePayload(payload); const mcpTransportFailure = normalized.isBash ? null : detectMcpTransportFailure(payload); if (mcpTransportFailure) { const fallbackCommand = buildRcsParityFallbackCommand(payload, mcpTransportFailure.toolName); const fallbackText = fallbackCommand ? `Retry via CLI parity with \`${fallbackCommand}\`.` : "Retry via the matching RCS CLI parity surface instead of retrying the MCP transport blindly."; return { decision: "block", reason: "The MCP tool appears to have lost its transport/server connection. Preserve state, debug the transport failure, and use RCS CLI/file-backed fallbacks instead of retrying blindly.", hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: `Clear MCP transport-death signal detected. Preserve current team/runtime state. ${fallbackText} RCS MCP servers are plain Node stdio processes, so they still shut down when stdin/transport closes. If this happened during team runtime, inspect first with \`rcs team status \` or \`rcs team api read-stall-state --input '{"team_name":""}' --json\`, and only force cleanup after capturing needed state. For root-cause debugging, rerun with \`RCS_MCP_TRANSPORT_DEBUG=1\` to log why the stdio transport closed.`, }, }; } if (!normalized.isBash) return null; const combined = `${normalized.stderrText}\n${normalized.stdoutText}`.trim(); if (hasActionableBashHardFailure(normalized)) { return { decision: "block", reason: "The Bash output indicates a command/setup failure that should be fixed before retrying.", hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: "Bash reported `command not found`, `permission denied`, or a missing file/path. Verify the command, dependency installation, PATH, file permissions, and referenced paths before retrying.", }, }; } if ( normalized.exitCode !== null && normalized.exitCode !== 0 && combined.length > 0 && !containsHardFailure(combined) ) { return { decision: "block", reason: "The Bash command returned a non-zero exit code but produced useful output that should be reviewed before retrying.", hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: "The Bash output appears informative despite the non-zero exit code. Review and report the output before retrying instead of assuming the command simply failed.", }, }; } return null; }