Files
oh-my-claudecode/scripts/keyword-detector.mjs

1996 lines
80 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* OMC Keyword Detector Hook (Node.js)
* Detects magic keywords and invokes skill tools
* Cross-platform: Windows, macOS, Linux
*
* Supported keywords (in priority order):
* 1. cancelomc/stopomc: Stop active modes
* 2. ralph: Persistence mode until task completion
* 3. autopilot: Full autonomous execution
* 4. team: Explicit-only via /team (not auto-triggered)
* 5. ralplan: Iterative planning with consensus
* 6. deep interview: Socratic interview workflow
* 7. ai-slop-cleaner: Cleanup/deslop anti-slop workflow
* 8. tdd: Test-driven development
* 9. code review: Comprehensive review mode
* 10. security review: Security-focused review mode
* 11. ultrathink: Extended reasoning
* 12. deepsearch: Codebase search (restricted patterns)
* 13. analyze: Analysis mode (restricted patterns)
*/
import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
import { join, dirname, isAbsolute } from 'path';
import { homedir } from 'os';
import { fileURLToPath } from 'url';
import { getClaudeConfigDir } from './lib/config-dir.mjs';
import { atomicWriteFileSync, recoverEmergencyStateFile, withStateFileLockSync } from './lib/atomic-write.mjs';
import { readStdin } from './lib/stdin.mjs';
import { resolveOmcStateRoot, resolveSessionStatePathsForHook } from './lib/state-root.mjs';
import { parseWorkflowInvocation, selectWorkflowProfile, createWorkflowState, isValidWorkflowTrackingState, isWorkflowRuntimeSupported, resolveWorkflowStagePrompt, takeWorkflowTranscriptFailure } from './lib/workflow-profile-runtime.mjs';
// Resolve OMC package root: CLAUDE_PLUGIN_ROOT (plugin system) or derive from this script's location
const _omcRoot = process.env.CLAUDE_PLUGIN_ROOT ||
join(dirname(fileURLToPath(import.meta.url)), '..');
const SKILL_INVOCATION_USER_REQUEST_MAX = 1200;
const RALPLAN_FOLLOWUP_TERMINAL_PHASES = new Set([
'completed',
'complete',
'failed',
'cancelled',
'canceled',
'aborted',
'terminated',
'done',
'handoff',
'pending approval',
'pending-approval',
'pending_approval',
'awaiting approval',
'awaiting-approval',
'awaiting_approval',
'approval-required',
'approval_required',
]);
function normalizeRalplanFollowupPhase(state) {
if (!state || typeof state !== 'object') return null;
const raw = state.current_phase ?? state.phase ?? state.status;
if (typeof raw !== 'string') return null;
const phase = raw.trim().toLowerCase();
if (!phase) return null;
if (phase === 'handoff' || phase.startsWith('handoff:') || phase.startsWith('handoff-')) return 'handoff';
return phase;
}
function readRalplanFollowupState(statePaths, sessionId) {
const safeSessionId = sessionId && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId) ? sessionId : '';
for (const statePath of statePaths) {
try {
if (!existsSync(statePath)) continue;
const state = JSON.parse(readFileSync(statePath, 'utf-8'));
if (!state || typeof state !== 'object') continue;
if (safeSessionId && typeof state.session_id === 'string' && state.session_id !== safeSessionId) continue;
return state;
} catch { /* ignore malformed state */ }
}
return null;
}
function isRalplanFollowupTerminal(state) {
if (!state || typeof state !== 'object') return false;
if (state.active === true) return false;
const phase = normalizeRalplanFollowupPhase(state);
return Boolean(phase && RALPLAN_FOLLOWUP_TERMINAL_PHASES.has(phase));
}
function compactHookText(text, maxChars = SKILL_INVOCATION_USER_REQUEST_MAX) {
const notice = '\n...[truncated; original user prompt remains available in the conversation]';
if (!text || text.length <= maxChars) return text || '';
if (maxChars <= notice.length) return notice.slice(0, Math.max(0, maxChars));
return `${text.slice(0, maxChars - notice.length).trimEnd()}${notice}`;
}
function getSkillPathCandidates(skillName) {
const roots = [
process.env.CLAUDE_PLUGIN_ROOT,
_omcRoot,
process.cwd(),
].filter(Boolean);
return [...new Set(roots.map(root => join(root, 'skills', skillName, 'SKILL.md')))];
}
function resolveSkillPath(skillName) {
for (const skillPath of getSkillPathCandidates(skillName)) {
if (existsSync(skillPath)) return skillPath;
}
return getSkillPathCandidates(skillName)[0] || `skills/${skillName}/SKILL.md`;
}
const ULTRATHINK_MESSAGE = `<think-mode>
**ULTRATHINK MODE ENABLED** - Extended reasoning activated.
You are now in deep thinking mode. Take your time to:
1. Thoroughly analyze the problem from multiple angles
2. Consider edge cases and potential issues
3. Think through the implications of each approach
4. Reason step-by-step before acting
Use your extended thinking capabilities to provide the most thorough and well-reasoned response.
</think-mode>
---
`;
const SEARCH_MESSAGE = `<search-mode>
MAXIMIZE SEARCH EFFORT. Launch multiple background agents IN PARALLEL:
- explore agents (codebase patterns, file structures)
- document-specialist agents (remote repos, official docs, GitHub examples)
Plus direct tools: Grep, Glob
NEVER stop at first result - be exhaustive.
</search-mode>
---
`;
const ANALYZE_MESSAGE = `<analyze-mode>
ANALYSIS MODE. Gather context before diving deep:
- Search relevant code paths first
- Compare working vs broken behavior
- Synthesize findings before proposing changes
</analyze-mode>
---
`;
const TDD_MESSAGE = `<tdd-mode>
[TDD MODE ACTIVATED]
Write or update tests first when practical, confirm they fail for the right reason, then implement the minimal fix and re-run verification.
</tdd-mode>
---
`;
const CODE_REVIEW_MESSAGE = `<code-review-mode>
[CODE REVIEW MODE ACTIVATED]
Perform a comprehensive code review of the relevant changes or target area. Focus on correctness, maintainability, edge cases, regressions, and test adequacy before recommending changes.
</code-review-mode>
---
`;
const SECURITY_REVIEW_MESSAGE = `<security-review-mode>
[SECURITY REVIEW MODE ACTIVATED]
Perform a focused security review of the relevant changes or target area. Check trust boundaries, auth/authz, data exposure, input validation, command/file access, secrets handling, and escalation risks before recommending changes.
</security-review-mode>
---
`;
const MODE_MESSAGE_KEYWORDS = new Map([
['ultrathink', ULTRATHINK_MESSAGE],
['deepsearch', SEARCH_MESSAGE],
['analyze', ANALYZE_MESSAGE],
['tdd', TDD_MESSAGE],
['code-review', CODE_REVIEW_MESSAGE],
['security-review', SECURITY_REVIEW_MESSAGE],
]);
// Extract prompt from various JSON structures
function extractPrompt(input) {
try {
const data = JSON.parse(input);
if (data.prompt) return data.prompt;
if (data.message?.content) return data.message.content;
if (Array.isArray(data.parts)) {
return data.parts
.filter(p => p.type === 'text')
.map(p => p.text)
.join(' ');
}
return '';
} catch {
// Fail closed: don't risk false-positive keyword detection from malformed input
return '';
}
}
function isExplicitRalplanSlashInvocation(prompt) {
return /^\s*\/(?:oh-my-claudecode:)?ralplan(?:\s|$)/i.test(prompt);
}
function isExplicitAskSlashInvocation(prompt) {
return /^\s*\/(?:oh-my-claudecode:)?ask\s+(?:claude|codex|gemini|antigravity|agy|grok|cursor)\b/i.test(prompt);
}
function isRetiredSlashInvocation(prompt) {
return /^\s*\/(?:omc:|oh-my-claudecode:)?(?:ultrawork|ulw|uw|울트라워크|ウルトラワーク|ccg|claude-codex-gemini|씨씨지|シーシージー)(?=\s|$|[?!.,;:])/i.test(prompt);
}
// Sanitize text to prevent false positives from code blocks, XML tags, URLs, and file paths
const ANTI_SLOP_EXPLICIT_PATTERN = /\b(ai[\s-]?slop|anti[\s-]?slop|deslop|de[\s-]?slop)\b/i;
const ANTI_SLOP_ACTION_PATTERN = /\b(clean(?:\s*up)?|cleanup|refactor|simplify|dedupe|de-duplicate|prune)\b/i;
const ANTI_SLOP_SMELL_PATTERN = /\b(slop|duplicate(?:d|s)?|duplication|dead\s+code|unused\s+code|over[\s-]?abstract(?:ion|ed)?|wrapper\s+layers?|boundary\s+violations?|needless\s+abstractions?|unnecessary\s+abstractions?|ai[\s-]?generated|generated\s+code|tech\s+debt)\b/i;
function isAntiSlopCleanupRequest(text) {
return ANTI_SLOP_EXPLICIT_PATTERN.test(text) ||
(ANTI_SLOP_ACTION_PATTERN.test(text) && ANTI_SLOP_SMELL_PATTERN.test(text));
}
/** Review-outcome labels that appear together in seeded review instruction text. */
const REVIEW_SEED_OUTCOME_RES = [
/\bapprove\b/i,
/\brequest[- ]changes\b/i,
/\bmerge[- ]ready\b/i,
/\bblocked\b/i,
];
/**
* Returns true when the prompt looks like echoed review-instruction text
* (an injected outcome menu: approve / request-changes / merge-ready / blocked),
* not a genuine user intent to start review mode.
*
* Heuristic: ≥2 distinct outcome labels in the first 20 lines → seeded context.
*/
function isReviewSeedContext(text) {
const preview = text.split('\n').slice(0, 20).join('\n');
return REVIEW_SEED_OUTCOME_RES.filter(re => re.test(preview)).length >= 2;
}
function sanitizeForKeywordDetection(text) {
return stripPastedCommandPayloads(text)
// 0. Strip HTML/markdown comments before tag stripping
.replace(/<!--[\s\S]*?-->/g, '')
// 1. Strip XML-style tag blocks: <tag-name ...>...</tag-name> (multi-line, greedy on tag name)
.replace(/<(\w[\w-]*)[\s>][\s\S]*?<\/\1>/g, '')
// 2. Strip self-closing XML tags: <tag-name />, <tag-name attr="val" />
.replace(/<\w[\w-]*(?:\s[^>]*)?\s*\/>/g, '')
// 3. Strip URLs: http://... or https://... up to whitespace
.replace(/https?:\/\/[^\s)>\]]+/g, '')
// 3.5 Strip block quotes and markdown table rows - these are usually reference content
.replace(/^\s*>\s.*$/gm, '')
.replace(/^\s*\|(?:[^|\n]*\|){2,}\s*$/gm, '')
.replace(/^\s*\|?(?:\s*:?-{3,}:?\s*\|){1,}\s*$/gm, '')
// 4. Strip file paths — uses lookbehind (Node.js supports it). Requires at least one
// slash-terminated directory segment (?:SEG+\/)+ so bare slash-commands like /ralph are NOT
// stripped (the .mjs has no pre-sanitization slash handler for them). Directory/stem segments
// are Unicode-aware (\w.- plus the NON_LATIN_SCRIPT_PATTERN ranges) so CJK file names like
// docs/コードレビュー.md are stripped. The final segment is bounded: a CJK-capable stem ending
// in a .ext, OR an ASCII-only extensionless name — so a no-space CJK directive following a path
// (e.g. src/auth.tsをコードレビューして) is NOT consumed by a greedy CJK tail and the alias
// still activates.
.replace(/(?<=^|[\s"'`(])(?:\/)?(?:[\w.\- -鿿가-힯Ѐ-ӿ؀-ۿऀ-ॿ฀-๿က-႟]+\/)+(?:[\w.\- -鿿가-힯Ѐ-ӿ؀-ۿऀ-ॿ฀-๿က-႟]*\.\w+|[\w.\-]+)/gm, '')
// 5. Strip markdown code blocks (existing)
.replace(/```[\s\S]*?```/g, '')
// 6. Strip inline code (existing)
.replace(/`[^`]+`/g, '');
}
const PASTED_MAGIC_KEYWORD_HEADER_PATTERN =
/^\s*\[MAGIC KEYWORDS?(?: DETECTED)?:.*$/i;
const ROLE_BOUNDARY_PATTERN =
/^<\s*\/?\s*(system|human|assistant|user|tool_use|tool_result)\b[^>]*>/i;
const SKILL_TRANSCRIPT_LINE_PATTERN =
/^\s*Skill:\s+oh-my-(?:claudecode|codex):/i;
const USER_REQUEST_LINE_PATTERN = /^\s*User request(?:\s*\([^)]*\))?:\s*$/i;
const SHELL_TRANSCRIPT_LINE_PATTERN = /^\s*[$%]\s+/;
const GIT_DIFF_START_PATTERNS = [
/^diff\s+--git\s+a\//,
/^index\s+[0-9a-f]+\.\.[0-9a-f]+(?:\s+\d+)?$/i,
/^(?:---|\+\+\+)\s+[ab]\//,
/^@@\s+-\d+/,
];
const GIT_DIFF_CONTINUATION_PATTERNS = [
/^new file mode\s+\d+$/i,
/^deleted file mode\s+\d+$/i,
/^similarity index\s+\d+%$/i,
/^rename (?:from|to)\s+/i,
/^Binary files .+ differ$/i,
/^(?:diff\s+--git\s+a\/|index\s+[0-9a-f]+\.\.[0-9a-f]+|(?:---|\+\+\+)\s+[ab]\/|@@\s+-\d+)/i,
/^[ +\-].*/,
];
function stripPastedCommandPayloads(text) {
const lines = text.split('\n');
const sanitized = [];
let insideRoleBlock = false;
let insideDiffBlock = false;
let insideMagicKeywordBlock = false;
let magicBlockSawUserRequest = false;
let magicBlockSawRequestPayload = false;
let previousLineWasUserRequest = false;
for (const line of lines) {
const trimmed = line.trim();
if (insideMagicKeywordBlock) {
if (ROLE_BOUNDARY_PATTERN.test(trimmed)) {
insideRoleBlock = !/^<\s*\//.test(trimmed);
insideMagicKeywordBlock = false;
magicBlockSawUserRequest = false;
magicBlockSawRequestPayload = false;
continue;
}
if (USER_REQUEST_LINE_PATTERN.test(line)) {
magicBlockSawUserRequest = true;
magicBlockSawRequestPayload = false;
continue;
}
if (magicBlockSawUserRequest) {
if (trimmed) {
magicBlockSawRequestPayload = true;
continue;
}
if (magicBlockSawRequestPayload) {
insideMagicKeywordBlock = false;
magicBlockSawUserRequest = false;
magicBlockSawRequestPayload = false;
sanitized.push(line);
continue;
}
}
continue;
}
if (PASTED_MAGIC_KEYWORD_HEADER_PATTERN.test(line)) {
insideMagicKeywordBlock = true;
magicBlockSawUserRequest = false;
magicBlockSawRequestPayload = false;
continue;
}
if (ROLE_BOUNDARY_PATTERN.test(trimmed)) {
insideRoleBlock = !/^<\s*\//.test(trimmed);
continue;
}
if (insideRoleBlock) {
continue;
}
if (!trimmed) {
sanitized.push(line);
insideDiffBlock = false;
previousLineWasUserRequest = false;
continue;
}
if (previousLineWasUserRequest) {
previousLineWasUserRequest = false;
continue;
}
if (USER_REQUEST_LINE_PATTERN.test(line) || SKILL_TRANSCRIPT_LINE_PATTERN.test(line)) {
previousLineWasUserRequest = USER_REQUEST_LINE_PATTERN.test(line);
continue;
}
if (SHELL_TRANSCRIPT_LINE_PATTERN.test(line) && !/^\s*\$\w/.test(line)) {
continue;
}
if (insideDiffBlock) {
if (GIT_DIFF_CONTINUATION_PATTERNS.some((pattern) => pattern.test(trimmed))) {
continue;
}
insideDiffBlock = false;
}
if (GIT_DIFF_START_PATTERNS.some((pattern) => pattern.test(trimmed))) {
insideDiffBlock = true;
continue;
}
sanitized.push(line);
}
return sanitized.join('\n');
}
const INFORMATIONAL_INTENT_PATTERNS = [
/\b(?:what(?:'s|\s+is)|what\s+are|how\s+(?:to|do\s+i)\s+use|explain|explanation|tell\s+me\s+about|describe)\b/i,
/(?:뭐야|뭔데|무엇(?:이야|인가요)?|어떻게|설명(?!서\s*(?:작성|만들|생성|추가|업데이트|수정|편집|쓰))|사용법|알려\s?줘|알려줄래|소개해?\s?줘|소개\s*부탁|설명해\s?줘|뭐가\s*달라|어떤\s*기능|기능\s*(?:알려|설명|뭐)|방법\s*(?:알려|설명|뭐))/u,
/(?:とは|って何|使い方|説明|(?:について|に関して|違い)[^\n]{0,24}(?:教えて|説明|知りたい)|(?:どう|何が|どこが)違う)/u,
/(?:什么是|什麼是|怎(?:么|樣)用|如何使用|解释|說明|说明)/u,
/(?:ทำไม|อะไร|ยังไง|อย่างไร|คืออะไร|หมายถึง|แปลว่า|อธิบาย|มั้ย|ไหม|เหรอ|หรอ|หรือไม่|หรือเปล่า|ใช่ไหม|ถูกมั้ย|เกี่ยวกับ|เหมือน)/u,
];
const INFORMATIONAL_CONTEXT_WINDOW = 80;
const QUOTED_SPAN_PATTERN =
/"[^"\n]{1,400}"|'[^'\n]{1,400}'|“[^”\n]{1,400}”|[^\n]{1,400}/g;
const REFERENCE_META_PATTERNS = [
/\b(?:vs\.?|versus|compared\s+to|comparison|compare|article|blog\s+post|documentation|docs?|reference)\b/i,
/(?:비교|차이|설명|정리|문서|자료|가이드|이\s*(?:글|비교|문서)는|블로그)/u,
/\b(?:this\s+(?:article|comparison|guide|documentation|doc)|quoted|quote(?:d)?)\b/i,
/(?:เปรียบเทียบ|ต่างกัน|ความต่าง|เอกสาร|บทความ|ไกด์|คู่มือ|เกี่ยวกับ|เหมือน)/u,
];
const REFERENCE_EXPLANATION_PATTERNS = [
/(?:^|\n)\s*(?:결론|특징|예시|요약|장점|단점|설명)\s*[:]/u,
/\b(?:summary|conclusion|key\s+points?|example|examples|pros|cons|overview)\s*:/i,
/[^\n]{1,80}=\s*["“]/,
/[→⇒]/,
];
const QUESTION_FOLLOWUP_PATTERNS = [
/\b(?:how\s+many|how\s+much|why|what\s+happened|what\s+went\s+wrong|token\s+budget|cost|pricing)\b/i,
/(?:왜|얼마|몇\s*번|몇번|토큰|가격|비용|질문)/u,
/(?:ทำไม|อะไร|ยังไง|อย่างไร|เท่าไหร่|กี่|มั้ย|ไหม|เหรอ|หรอ|หรือไม่|หรือเปล่า|ใช่ไหม|ถูกมั้ย)/u,
];
// Patterns that identify system-generated echoes (hook outputs) which can be
// pasted back into the prompt verbatim. If a mode keyword only appears inside
// such an echo block we MUST NOT re-activate the mode: otherwise a user who
// copies a "[RALPH LOOP - ITERATION N] ..." block into a new session to debug
// it will unintentionally re-trigger ralph, and the pasted text ends up as
// the new state.prompt — producing a recursive self-reinforcing loop.
// Continuation lines that hook output typically emits DIRECTLY after a
// recognized block header. They must be stripped only in that context —
// never standalone — because a user might legitimately start a prompt with
// "Task: …" or similar (Codex automated review P1/P2 on #2795).
const ECHO_CONTINUATION = '(?:\\r?\\n[ \\t]*(?:Task:\\s|When FULLY complete \\(after Architect verification\\)|run\\s+\\/oh-my-claudecode:cancel).*)*';
// NOTE: each pattern is a SINGLE LOGICAL BLOCK: the block header line +
// zero-or-more continuation lines that hooks emit right after it. The whole
// match is stripped together. Both `i` (case-insensitive against the
// lower-cased cleanPrompt upstream) and `m` (so `^`/`$` match line
// boundaries) are required.
function buildEchoBlockRegex(headerBody) {
return new RegExp(`^[ \\t]*${headerBody}.*${ECHO_CONTINUATION}$`, 'gim');
}
const SYSTEM_ECHO_BLOCK_PATTERNS = [
// persistent-mode.mjs block headers
buildEchoBlockRegex('\\[RALPH LOOP\\s*-\\s*ITERATION[^\\]\\n]*\\]'),
buildEchoBlockRegex('\\[RALPH LOOP\\s*-\\s*(?:HARD LIMIT|EXTENDED)\\]'),
buildEchoBlockRegex('\\[TEAM\\s*-\\s*Phase:[^\\]\\n]*\\]'),
buildEchoBlockRegex('\\[AUTOPILOT[^\\]\\n]*\\]'),
buildEchoBlockRegex('\\[ULTRAPILOT[^\\]\\n]*\\]'),
buildEchoBlockRegex('\\[ULTRAWORK[^\\]\\n]*\\]'),
buildEchoBlockRegex('\\[PIPELINE[^\\]\\n]*\\]'),
buildEchoBlockRegex('\\[SWARM[^\\]\\n]*\\]'),
buildEchoBlockRegex('\\[TOOL ERROR[^\\]\\n]*\\]'),
// keyword-detector.mjs block headers
buildEchoBlockRegex('\\[MAGIC KEYWORD:[^\\]\\n]*\\]'),
buildEchoBlockRegex('\\[MAGIC KEYWORDS DETECTED:[^\\]\\n]*\\]'),
// Stop-hook wrapping by the Claude Code harness
buildEchoBlockRegex('Stop hook (?:blocking error|feedback|stopped continuation)'),
buildEchoBlockRegex('PreToolUse:[^\\n]*hook additional context:'),
buildEchoBlockRegex('PostToolUse:[^\\n]*hook additional context:'),
];
// Signature lines indicating the text is predominantly a system echo. Even
// when the block patterns above fail to delimit cleanly (e.g. truncation),
// presence of these sentinels should make us treat the prompt as an echo.
// All patterns use `i` because hasActionableKeyword sees a lowercased prompt.
const SYSTEM_ECHO_SIGNATURES = [
/\bWhen FULLY complete \(after Architect verification\)\b/i,
/\brun\s+\/oh-my-claudecode:cancel\b/i,
/\[RALPH LOOP\s*-\s*ITERATION\b/i,
];
const MAX_STATE_PROMPT_LEN = 500;
function stripSystemEchoes(text) {
if (typeof text !== 'string' || text.length === 0) return '';
let cleaned = text;
for (const pattern of SYSTEM_ECHO_BLOCK_PATTERNS) {
cleaned = cleaned.replace(pattern, ' ');
}
return cleaned;
}
function looksLikeSystemEcho(text) {
if (typeof text !== 'string' || text.length === 0) return false;
if (SYSTEM_ECHO_SIGNATURES.some((pattern) => pattern.test(text))) return true;
// Also treat the presence of ANY echo block pattern as an echo, so that
// mode-specific paste blocks (AUTOPILOT, ULTRAWORK, TEAM, etc.) are
// recognized without needing a dedicated signature line.
for (const pattern of SYSTEM_ECHO_BLOCK_PATTERNS) {
const probe = new RegExp(pattern.source, pattern.flags.replace('g', ''));
if (probe.test(text)) return true;
}
return false;
}
/**
* Sanitize a prompt before persisting it to a *-state.json file.
* Returning the raw prompt risks storing large system echoes or pasted
* hook output, which persistent-mode.mjs would then blast back into the
* next Stop-hook block reason on every iteration.
*
* Strategy:
* 1. Strip echoes first. If non-empty content remains AND that remainder no
* longer looks like an echo, keep it (preserves the real user request in
* an "echo + blank line + real request" paste).
* 2. Otherwise, if the raw prompt looks like a pure echo, substitute the
* placeholder sentinel.
* 3. Finally, hard-truncate to MAX_STATE_PROMPT_LEN chars.
*/
function sanitizePromptForState(prompt) {
if (typeof prompt !== 'string') return '';
const trimmed = prompt.trim();
if (!trimmed) return '';
const stripped = stripSystemEchoes(trimmed).trim();
if (stripped.length > 0 && !looksLikeSystemEcho(stripped)) {
return stripped.length > MAX_STATE_PROMPT_LEN
? `${stripped.slice(0, MAX_STATE_PROMPT_LEN - 3)}...`
: stripped;
}
if (looksLikeSystemEcho(trimmed)) {
return '(prompt omitted: pasted system echo)';
}
// Fallback (stripping left nothing but original isn't recognizably an echo)
const base = stripped.length > 0 ? stripped : trimmed;
return base.length > MAX_STATE_PROMPT_LEN
? `${base.slice(0, MAX_STATE_PROMPT_LEN - 3)}...`
: base;
}
const MODE_REFERENCE_PATTERN =
/\b(?:ralph|autopilot|auto[\s-]?pilot|ultragoal|ultrawork|ulw|ralplan|ultrathink|deepsearch|deep[\s-]?analyze|deepanalyze|deep[\s-]interview|ouroboros|ccg|claude-codex-gemini|deerflow)\b/gi;
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getLineBounds(text, position) {
const start = text.lastIndexOf('\n', Math.max(0, position - 1)) + 1;
const nextNewline = text.indexOf('\n', position);
const end = nextNewline === -1 ? text.length : nextNewline;
return { start, end };
}
function isWithinQuotedSpan(text, position) {
for (const match of text.matchAll(QUOTED_SPAN_PATTERN)) {
if (match.index === undefined) continue;
const start = match.index;
const end = start + match[0].length;
if (position >= start && position < end) {
return true;
}
}
return false;
}
// Bounds of the specific quoted span containing `position`, or null if none.
// Used to scope the execution-directive check for the quote exemption to
// this keyword's own quote — not the generic ±80-char context window, which
// can otherwise pick up an unrelated genuine command elsewhere in the same
// message and wrongly neutralize the exemption for a keyword that is purely
// quoted as an example.
function findQuotedSpanBounds(text, position) {
for (const match of text.matchAll(QUOTED_SPAN_PATTERN)) {
if (match.index === undefined) continue;
const start = match.index;
const end = start + match[0].length;
if (position >= start && position < end) {
return { start, end };
}
}
return null;
}
function stripQuotedSpans(text) {
return text.replace(QUOTED_SPAN_PATTERN, ' ');
}
function countDistinctModeReferences(text) {
const matches = text.match(MODE_REFERENCE_PATTERN) ?? [];
const normalized = new Set(
matches.map((match) => match.toLowerCase().replace(/\s+/g, '').replace(/-/g, '')),
);
return normalized.size;
}
function looksLikeReferenceContent(text) {
const hasReferenceMeta = REFERENCE_META_PATTERNS.some((pattern) => pattern.test(text));
const hasExplanationShape = REFERENCE_EXPLANATION_PATTERNS.some((pattern) => pattern.test(text));
const hasAnyModeMention = countDistinctModeReferences(text) >= 1;
const hasMultipleModeMentions = countDistinctModeReferences(text) >= 2;
const hasQuestionOutsideQuotes = QUESTION_FOLLOWUP_PATTERNS.some((pattern) =>
pattern.test(stripQuotedSpans(text)),
);
return (
(hasReferenceMeta && (hasExplanationShape || hasAnyModeMention || hasQuestionOutsideQuotes)) ||
(hasExplanationShape && (hasMultipleModeMentions || hasQuestionOutsideQuotes)) ||
(hasMultipleModeMentions && hasQuestionOutsideQuotes)
);
}
function hasActivationIntentNearKeyword(context, keyword) {
const escaped = escapeRegExp((keyword || '').trim());
if (!escaped) return false;
const patterns = [
new RegExp(`\\b(?:use|run|start|enable|activate|invoke|trigger|launch)\\b[^\\n]{0,28}\\b${escaped}\\b`, 'i'),
new RegExp(`\\b(?:fix|debug|investigate|resolve|handle|patch|address)\\b[^\\n]{0,28}\\b(?:issue|bug|problem|error)\\b[^\\n]{0,12}\\b(?:with|in)\\s+\\b${escaped}\\b`, 'i'),
];
return patterns.some((pattern) => pattern.test(context));
}
function hasDirectInvocationPrefix(text, position) {
const prefix = text.slice(0, position);
return /^\s*(?:[$/!]\s*|force:\s*|oh-my-(?:claudecode|codex):\s*)?$/i.test(prefix);
}
function hasConversationalInvocationNearKeyword(text, position, _keywordLength, _keywordText) {
if (isWithinQuotedSpan(text, position)) {
return false;
}
const start = Math.max(0, position - INFORMATIONAL_CONTEXT_WINDOW);
const prefix = stripQuotedSpans(text.slice(start, position));
const conversationalInvocationPatterns = [
/\bplease\s+$/i,
/\blet[']?s\s+$/i,
/\bi\s+(?:want|need|would\s+like)\s+(?:a|an)\s+$/i,
/\b(?:can|could|would|will)\s+you\s+$/i,
];
return conversationalInvocationPatterns.some((pattern) => pattern.test(prefix));
}
function hasExplicitInvocationContext(text, position, keywordLength, keywordText) {
if (hasDirectInvocationPrefix(text, position)) {
return true;
}
const start = Math.max(0, position - INFORMATIONAL_CONTEXT_WINDOW);
const end = Math.min(text.length, position + keywordLength + INFORMATIONAL_CONTEXT_WINDOW);
const context = text.slice(start, end);
if (hasActivationIntentNearKeyword(context, keywordText)) {
return true;
}
return hasConversationalInvocationNearKeyword(text, position, keywordLength, keywordText);
}
function hasExplicitRalphInvocationContext(text, position, keywordLength, keywordText) {
const normalizedKeyword = (keywordText || '').toLowerCase().replace(/\s+/g, '');
const prefix = text.slice(0, position);
const suffix = text.slice(position + keywordLength);
if (/^\s*(?:[$/!]\s*|force:\s*|\/?oh-my-(?:claudecode|codex):\s*)$/i.test(prefix)) {
return true;
}
const start = Math.max(0, position - INFORMATIONAL_CONTEXT_WINDOW);
const end = Math.min(text.length, position + keywordLength + INFORMATIONAL_CONTEXT_WINDOW);
const context = text.slice(start, end);
if (hasActivationIntentNearKeyword(context, keywordText)) {
return true;
}
if (normalizedKeyword === '랄프' || normalizedKeyword === 'ラルフ') {
return /^\s*(?:켜|켜줘|실행|시작|돌려|돌려줘|써|써줘|사용해|진행해|起動|開始|実行|使って|やって|を?実行|を?起動|を?開始)/u.test(suffix);
}
if (normalizedKeyword !== 'ralph') {
return false;
}
if (/^\s*[:]\s*\S/.test(suffix)) {
return true;
}
return /^['"]?\s+(?:this\b|and\s+)?(?:fix\b|debug\b|investigate\b|resolve\b|handle\b|patch\b|address\b|implement\b|run\b|start\b|enable\b|activate\b|invoke\b|trigger\b|launch\b)|^['"]?\s+this\b|^\s+the\s+[^.?!\n]{0,60}\buntil\b/i.test(suffix);
}
function hasDiagnosticIntentNearKeyword(context, keyword) {
const escaped = escapeRegExp((keyword || '').trim());
if (!escaped) return false;
const patterns = [
new RegExp(`\\b${escaped}\\b[^\\n]{0,48}\\b(?:keeps?\\s+(?:looping|re-?running)|has\\s+(?:a\\s+)?(?:bug|issue|problem|error)|is\\s+(?:stuck|broken|failing)|loop(?:ing)?)\\b`, 'i'),
new RegExp(`\\b(?:bug|issue|problem|error)\\b[^\\n]{0,16}\\b(?:with|in)\\s+\\b${escaped}\\b`, 'i'),
new RegExp(`${escaped}.{0,14}(?:자꾸|계속).{0,14}(?:재실행|반복|루프|멈추)`, 'u'),
// Japanese: repeated-failure complaint — direct mirror of the Korean 자꾸/계속 line above
// (frequency adverb + problem verb). No P2 subject-particle pattern / no work-request escape: Korean parity.
new RegExp(`${escaped}[^\\n]{0,16}(?:また|何度も|ずっと|頻繁|繰り返|いつも)[^\\n]{0,16}(?:失敗|エラー|ループ|止ま|落ち|再実行|動かな|フリーズ|壊れ|クラッシュ|こけ|暴走|無限)`, 'u'),
];
return patterns.some((pattern) => pattern.test(context));
}
function isAutopilotCreationAlias(keywordText) {
const normalized = (keywordText || '').toLowerCase().trim();
return /^(?:build|create|make)\s+me\b/.test(normalized) || /^i\s+want\s+an?(?:\s+(?:app|feature|project|tool|plugin|website|api|server|cli|script|system|service|dashboard|bot|extension))?\s*$/.test(normalized);
}
function hasActionableCommandAfterSeparator(text, position, keywordLength) {
const suffix = text.slice(position + keywordLength).match(/^\s*[:]\s*([^\n]{0,80})/u)?.[1] ?? '';
if (/\?||\b(?:what(?:'s|\s+is)|how\s+(?:to|do\s+i)\s+use|explain|describe|tell\s+me\s+about)\b/iu.test(suffix)) {
return false;
}
return /\b(?:fix|debug|investigate|resolve|handle|patch|address|implement|build|create|make|run|start|enable|activate|invoke|trigger|launch)\b|(?:ทำ|ทํา|สร้าง|แก้|เปิด|รัน|เรียก|เริ่ม)/iu.test(suffix);
}
function isRalphUltraworkMetaOrBanterContext(context, keywordText) {
const normalizedKeyword = (keywordText || '').toLowerCase().replace(/\s+/g, '');
if (!['ralph', '랄프', 'ラルフ', 'ultrawork', 'ulw', 'uw', '울트라워크', 'ウルトラワーク'].includes(normalizedKeyword)) {
return false;
}
const currentKeywordAliases = normalizedKeyword === 'ralph' || normalizedKeyword === '랄프' || normalizedKeyword === 'ラルフ'
? ['랄프', 'ラルフ']
: ['울트라워크', 'ウルトラワーク'];
const currentKeywordPattern = currentKeywordAliases.join('|');
const imperativeVerbPattern = '켜|켜줘|실행|시작|돌려|돌려줘|써|써줘|사용해|진행해';
const koreanImperativePatterns = [
new RegExp(`(?:${currentKeywordPattern})[^?\n]{0,16}(?:${imperativeVerbPattern})`, 'u'),
new RegExp(`(?:${imperativeVerbPattern})[^?\n]{0,16}(?:${currentKeywordPattern})`, 'u'),
];
if (koreanImperativePatterns.some((pattern) => pattern.test(context))) {
return false;
}
const metaOrBanterPatterns = [
/[?].{0,12}(?:ㅋ{1,}|ㅎ{1,}|lol|lmao)/iu,
/(?:ㅋ{1,}|ㅎ{1,}|lol|lmao).{0,40}[?]/iu,
/(?:ralph|랄프|ultrawork|ulw|uw|울트라워크).{0,40}(?:라도|줘야\s*해|쥐어\s*줘야\s*해|해야\s*해).{0,20}[?]/iu,
/(?:관계|관련|연관|차이|비교).{0,40}(?:뭐|무엇|어떻게|설명|알려|궁금|인가|야|냐|니|까|[?])/u,
/(?:뭐|무엇|어떻게|설명|알려|궁금).{0,40}(?:관계|관련|연관|차이|비교)/u,
];
return metaOrBanterPatterns.some((pattern) => pattern.test(context));
}
function isInformationalKeywordContext(text, position, keywordLength, keywordText) {
const start = Math.max(0, position - INFORMATIONAL_CONTEXT_WINDOW);
const end = Math.min(text.length, position + keywordLength + INFORMATIONAL_CONTEXT_WINDOW);
const context = text.slice(start, end);
const hasInformationalIntent = INFORMATIONAL_INTENT_PATTERNS.some((pattern) => pattern.test(context));
const hasStrongHelpQueryIntent = /\?||\b(?:how\s+(?:to|do\s+i)\s+use|what(?:'s|\s+is)|explain|describe|tell\s+me\s+about)\b|(?:사용법|使い方|什么是|怎么用|如何使用)/iu.test(context);
const lineBounds = getLineBounds(text, position);
const line = text.slice(lineBounds.start, lineBounds.end);
const questionOutsideQuotes = stripQuotedSpans(text);
const keywordInsideQuotes = isWithinQuotedSpan(text, position);
const hasExecutionDirective = /\b(?:fix|debug|investigate|resolve|handle|patch|address|implement|build)\b/i.test(context);
const hasCommandSeparatorInvocation =
hasDirectInvocationPrefix(text, position) && /^\s*[:]/.test(text.slice(position + keywordLength));
const hasActionableCommandSeparatorInvocation =
hasCommandSeparatorInvocation && hasActionableCommandAfterSeparator(text, position, keywordLength);
// A keyword occurrence inside a quoted span is usually reported/example
// text, not a command directed at the assistant — e.g. an example sentence
// like `"use autopilot"` inside a paragraph discussing that exact phrasing.
// But a quoted keyword paired with a nearby execution directive OR
// activation verb (e.g. `"ralph" fix the auth bug`, or `run "ralph" on
// this issue`) is still a genuine request stylistically wrapped in quotes,
// so the exemption only applies when neither is present.
//
// This check is scoped to text immediately OUTSIDE this keyword's own
// quoted span (±28 chars before/after the span's bounds), not the generic
// ±80-char context window used elsewhere in this function, and NOT the
// quote's own interior. Three failure modes this avoids:
// - Scoping to the wide window: an unrelated genuine command elsewhere in
// the same message could sit inside it and wrongly neutralize the
// exemption for a keyword that is purely quoted as an example.
// - Scoping to (or including) the quote's own interior: a directive or
// activation word used INSIDE the quoted text itself — extremely common
// in narrated examples and bug reports, e.g. `"please fix autopilot"
// they said` or `"...told it to use autopilot..."` — would make the
// quote self-report as command-bearing and defeat the exemption for
// exactly the reported-speech case it exists to catch.
// - Checking only execution-directive verbs (fix/debug/...) and not
// activation verbs (use/run/start/...): genuine commands stylistically
// quoting just the mode name, e.g. `run "ralph" on this issue` or
// `use "autopilot" on this task`, would be wrongly suppressed even
// though they activated before this exemption existed.
if (keywordInsideQuotes) {
const span = findQuotedSpanBounds(text, position);
const hasGenuineCommandNearQuote = span
? /\b(?:fix|debug|investigate|resolve|handle|patch|address|implement|build|use|run|start|enable|activate|invoke|trigger|launch)\b/i.test(
text.slice(Math.max(0, span.start - 28), span.start) +
' ' +
text.slice(span.end, Math.min(text.length, span.end + 28)),
)
: hasExecutionDirective;
if (!hasGenuineCommandNearQuote) {
return true;
}
}
if (keywordText) {
const hasActivationIntent = hasActivationIntentNearKeyword(context, keywordText);
if (hasActionableCommandSeparatorInvocation) {
return false;
}
if (isAutopilotCreationAlias(keywordText)) {
return false;
}
if (hasActivationIntent && hasExecutionDirective) {
return false;
}
if (hasInformationalIntent && hasStrongHelpQueryIntent) {
return true;
}
if (hasActivationIntent) {
return false;
}
if (hasConversationalInvocationNearKeyword(text, position, keywordLength, keywordText)) {
return false;
}
if (isRalphUltraworkMetaOrBanterContext(context, keywordText)) {
return true;
}
if (hasDiagnosticIntentNearKeyword(context, keywordText)) {
return true;
}
}
if (/^\s*>\s/.test(line) || /^\s*\|(?:[^|\n]*\|){2,}\s*$/.test(line)) {
return true;
}
if (keywordInsideQuotes && QUESTION_FOLLOWUP_PATTERNS.some((pattern) => pattern.test(questionOutsideQuotes))) {
return true;
}
if (looksLikeReferenceContent(text)) {
return true;
}
return hasInformationalIntent;
}
function hasActionableKeyword(text, pattern) {
// Strip system-generated echo blocks (persistent-mode/keyword-detector
// outputs, Stop-hook wrappers) BEFORE searching for keywords. Otherwise
// pasting a previous "[RALPH LOOP - ITERATION N] ..." block into a prompt
// would re-activate ralph mode and the echo itself would be saved as
// state.prompt, which persistent-mode.mjs then blasts back on every
// iteration — a self-reinforcing loop that is hard to cancel.
const searchText = looksLikeSystemEcho(text)
? stripSystemEchoes(text)
: text;
const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
const globalPattern = new RegExp(pattern.source, flags);
for (const match of searchText.matchAll(globalPattern)) {
if (match.index === undefined) {
continue;
}
if (isInformationalKeywordContext(searchText, match.index, match[0].length, match[0])) {
continue;
}
return true;
}
return false;
}
function hasExplicitWorkflowInvocationContext(text, position, keywordLength, keywordText) {
const prefix = text.slice(0, position);
if (/^\s*(?:[$/!]\s*|force:\s*|oh-my-(?:claudecode|codex):\s*)$/i.test(prefix)) {
return true;
}
const start = Math.max(0, position - INFORMATIONAL_CONTEXT_WINDOW);
const end = Math.min(text.length, position + keywordLength + INFORMATIONAL_CONTEXT_WINDOW);
const context = text.slice(start, end);
return hasActivationIntentNearKeyword(context, keywordText);
}
function hasExplicitActionableKeyword(text, pattern) {
const searchText = looksLikeSystemEcho(text)
? stripSystemEchoes(text)
: text;
const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
const globalPattern = new RegExp(pattern.source, flags);
for (const match of searchText.matchAll(globalPattern)) {
if (match.index === undefined) {
continue;
}
if (isInformationalKeywordContext(searchText, match.index, match[0].length, match[0])) {
continue;
}
if (!hasExplicitWorkflowInvocationContext(searchText, match.index, match[0].length, match[0])) {
continue;
}
return true;
}
return false;
}
function hasActionableRalphKeyword(text, pattern) {
const searchText = looksLikeSystemEcho(text)
? stripSystemEchoes(text)
: text;
const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
const globalPattern = new RegExp(pattern.source, flags);
for (const match of searchText.matchAll(globalPattern)) {
if (match.index === undefined) {
continue;
}
if (isInformationalKeywordContext(searchText, match.index, match[0].length, match[0])) {
continue;
}
if (!hasExplicitRalphInvocationContext(searchText, match.index, match[0].length, match[0])) {
continue;
}
return true;
}
return false;
}
function hasActionableRalplanKeyword(text, pattern) {
// Same echo guard as hasActionableKeyword.
const searchText = looksLikeSystemEcho(text)
? stripSystemEchoes(text)
: text;
const flags = pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`;
const globalPattern = new RegExp(pattern.source, flags);
for (const match of searchText.matchAll(globalPattern)) {
if (match.index === undefined) {
continue;
}
// match.index is relative to searchText — use searchText for all
// downstream context lookups to keep indices aligned.
if (isInformationalKeywordContext(searchText, match.index, match[0].length, match[0])) {
continue;
}
if (!hasExplicitInvocationContext(searchText, match.index, match[0].length, match[0])) {
continue;
}
return true;
}
return false;
}
const WORKFLOW_MARKER_KEYS = ['workflow', 'workflowRunId', 'pipelineTracking'];
function hasWorkflowMarker(state) {
return state !== null && typeof state === 'object' && !Array.isArray(state) &&
WORKFLOW_MARKER_KEYS.some((key) => Object.prototype.hasOwnProperty.call(state, key));
}
function hasValidWorkflowDescriptor(state, sessionId) {
return hasWorkflowMarker(state) &&
WORKFLOW_MARKER_KEYS.every((key) => Object.prototype.hasOwnProperty.call(state, key)) &&
isValidWorkflowTrackingState(state, sessionId);
}
// Create state file for a mode
async function activateState(directory, prompt, stateName, sessionId) {
const now = new Date().toISOString();
// Sanitize prompt BEFORE writing to state: prevents pasted system echoes
// and oversized blobs from being persisted and re-emitted by Stop hook.
const safePrompt = sanitizePromptForState(prompt);
let state;
if (stateName === 'ralph') {
// Ralph needs specific fields for proper loop tracking
state = {
active: true,
iteration: 1,
max_iterations: 100,
started_at: now,
prompt: safePrompt,
session_id: sessionId || undefined,
project_path: directory,
awaiting_confirmation: true,
awaiting_confirmation_set_at: now,
last_checked_at: now
};
} else if (stateName === 'ralplan') {
// Ralplan needs active + session_id for stop-hook enforcement
state = {
active: true,
started_at: now,
session_id: sessionId || undefined,
project_path: directory,
awaiting_confirmation: true,
awaiting_confirmation_set_at: now,
last_checked_at: now
};
} else if (stateName === 'ultragoal') {
// Ultragoal persists a durable goal workflow and requires Claude /goal parity.
state = {
active: true,
started_at: now,
current_phase: 'executing',
original_prompt: safePrompt,
session_id: sessionId || undefined,
project_path: directory,
reinforcement_count: 0,
max_reinforcements: 50,
awaiting_confirmation: true,
awaiting_confirmation_set_at: now,
last_checked_at: now
};
} else {
// Generic state for ultrawork, autopilot, etc.
state = {
active: true,
started_at: now,
original_prompt: safePrompt,
session_id: sessionId || undefined,
project_path: directory,
reinforcement_count: 0,
awaiting_confirmation: true,
awaiting_confirmation_set_at: now,
last_checked_at: now
};
}
const safeSessionId = sessionId && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId) ? sessionId : undefined;
const { writePath } = await resolveSessionStatePathsForHook(directory, stateName, safeSessionId);
try {
mkdirSync(dirname(writePath), { recursive: true });
let workflowIntegrityFailure = false;
withStateFileLockSync(writePath, () => {
if (!recoverEmergencyStateFile(writePath)) return;
// A legacy autopilot activation must never replace named state. Own
// markers are authoritative even when their values are falsy.
if (stateName === 'autopilot' && existsSync(writePath)) {
try {
const current = JSON.parse(readFileSync(writePath, 'utf8'));
if (hasWorkflowMarker(current)) {
if (!hasValidWorkflowDescriptor(current, sessionId)) workflowIntegrityFailure = true;
return;
}
} catch {
// A malformed existing state is not safe to replace while serialized.
return;
}
}
atomicWriteFileSync(writePath, JSON.stringify(state, null, 2));
});
return workflowIntegrityFailure ? 'workflow_descriptor_integrity_failed' : null;
} catch { return null; }
}
function retireStaleWorkflowCancelSignal(statePath, workflowRunId) {
const signalPath = join(dirname(statePath), 'cancel-signal-state.json');
withStateFileLockSync(signalPath, () => {
if (!existsSync(signalPath)) return;
try {
const signal = JSON.parse(readFileSync(signalPath, 'utf8'));
if (signal.target_workflow_run_id !== workflowRunId) unlinkSync(signalPath);
} catch {
// Malformed signals fail closed in Stop and are left for explicit cleanup.
}
});
}
function resumeWorkflowProfile(directory, sessionId, workflowName, omcRoot) {
const safeSessionId = sessionId && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId) ? sessionId : '';
const target = safeSessionId
? join(omcRoot, 'state', 'sessions', safeSessionId, 'autopilot-state.json')
: join(omcRoot, 'state', 'autopilot-state.json');
try {
mkdirSync(dirname(target), { recursive: true });
const result = withStateFileLockSync(target, () => {
if (!recoverEmergencyStateFile(target)) return { error: 'workflow_recovery_failure' };
if (!existsSync(target)) return null;
const current = JSON.parse(readFileSync(target, 'utf8'));
if (hasWorkflowMarker(current) && !hasValidWorkflowDescriptor(current, sessionId)) return { error: 'workflow_integrity_failure' };
if (!hasValidWorkflowDescriptor(current, sessionId) || current.active !== false || current.workflow.workflowName !== workflowName) return null;
const terminal = current.phase === 'complete' && current.pipelineTracking.currentStageIndex === current.workflow.stages.length;
if (terminal) return null;
const resumed = { ...current, active: true };
if (!isValidWorkflowTrackingState(resumed, sessionId)) return { error: takeWorkflowTranscriptFailure(sessionId) || 'workflow_integrity_failure' };
const stageId = resumed.workflow.stages[resumed.pipelineTracking.currentStageIndex];
const stagePrompt = resolveWorkflowStagePrompt(resumed, stageId);
if (!stagePrompt) return { error: 'workflow_integrity_failure' };
atomicWriteFileSync(target, JSON.stringify(resumed, null, 2));
return { stagePrompt, workflowRunId: resumed.workflowRunId };
});
if (result.value?.error === 'workflow_recovery_failure') throw new Error('workflow_emergency_recovery_failed');
if (result.value?.error === 'workflow_transcript_record_too_large') throw new Error('workflow_transcript_record_too_large');
if (result.value?.error) throw new Error('workflow_descriptor_integrity_failed');
if (!result.acquired || !result.value?.stagePrompt) return null;
retireStaleWorkflowCancelSignal(target, result.value.workflowRunId);
return result.value.stagePrompt;
} catch (error) {
if (error?.message === 'workflow_emergency_recovery_failed' || error?.message === 'workflow_transcript_record_too_large') throw error;
return false;
}
}
function activateWorkflowProfile(directory, sessionId, task, workflow, omcRoot, transcriptPath) {
const stateInput = { directory, sessionId, task, workflow, transcriptPath };
const safeSessionId = sessionId && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId) ? sessionId : '';
const target = safeSessionId
? join(omcRoot, 'state', 'sessions', safeSessionId, 'autopilot-state.json')
: join(omcRoot, 'state', 'autopilot-state.json');
try {
mkdirSync(dirname(target), { recursive: true });
const result = withStateFileLockSync(target, () => {
if (!recoverEmergencyStateFile(target)) return { error: 'workflow_recovery_failure' };
if (existsSync(target)) {
try {
const current = JSON.parse(readFileSync(target, 'utf8'));
if (hasWorkflowMarker(current) && !hasValidWorkflowDescriptor(current, sessionId)) return { error: 'workflow_integrity_failure' };
if (!hasValidWorkflowDescriptor(current, sessionId) && current?.active === true) return { error: 'active_workflow_conflict' };
if (hasValidWorkflowDescriptor(current, sessionId)) {
if (current.active === true) return { error: 'active_workflow_conflict' };
const terminal = current.phase === 'complete' && current.pipelineTracking.currentStageIndex === current.workflow.stages.length;
if (terminal) {
// A valid terminal state intentionally starts a fresh run below.
} else {
if (current.workflow.workflowName !== workflow.workflowName) return { error: 'active_workflow_conflict' };
const resumed = { ...current, active: true };
if (!isValidWorkflowTrackingState(resumed, sessionId)) return { error: takeWorkflowTranscriptFailure(sessionId) || 'workflow_integrity_failure' };
const stageId = resumed.workflow.stages[resumed.pipelineTracking.currentStageIndex];
const stagePrompt = resolveWorkflowStagePrompt(resumed, stageId);
if (!stagePrompt) return { error: 'workflow_integrity_failure' };
atomicWriteFileSync(target, JSON.stringify(resumed, null, 2));
return { stagePrompt, workflowRunId: resumed.workflowRunId };
}
}
} catch {
return { error: 'active_workflow_conflict' };
}
}
const state = createWorkflowState(stateInput);
if (!state) return { error: takeWorkflowTranscriptFailure(sessionId) || 'workflow_integrity_failure' };
const stagePrompt = resolveWorkflowStagePrompt(state, workflow.stages[0]);
if (!stagePrompt) return null;
atomicWriteFileSync(target, JSON.stringify(state, null, 2));
return { stagePrompt, workflowRunId: state.workflowRunId };
});
if (result.acquired && result.value?.error === 'active_workflow_conflict') throw new Error('an autopilot workflow is already active; run /cancel before activating another workflow');
if (result.acquired && result.value?.error === 'workflow_transcript_record_too_large') throw new Error('workflow_transcript_record_too_large');
if (result.acquired && result.value?.error === 'workflow_integrity_failure') throw new Error('workflow_descriptor_integrity_failed');
if (result.acquired && result.value?.error === 'workflow_recovery_failure') throw new Error('workflow_emergency_recovery_failed');
if (!result.acquired || !result.value || typeof result.value.stagePrompt !== 'string') return null;
retireStaleWorkflowCancelSignal(target, result.value.workflowRunId);
return result.value.stagePrompt;
} catch (error) {
if (error?.message === 'workflow_emergency_recovery_failed' || error?.message === 'workflow_transcript_record_too_large') throw error;
return false;
}
}
function activateRalplanStartupState(directory, prompt, sessionId, omcRoot) {
const _omcRoot = omcRoot;
const now = new Date().toISOString();
const state = {
active: true,
started_at: now,
current_phase: 'ralplan',
original_prompt: sanitizePromptForState(prompt),
session_id: sessionId || undefined,
project_path: directory,
awaiting_confirmation: true,
awaiting_confirmation_set_at: now,
last_checked_at: now
};
if (sessionId && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId)) {
const sessionDir = join(_omcRoot, 'state', 'sessions', sessionId);
if (!existsSync(sessionDir)) {
try { mkdirSync(sessionDir, { recursive: true }); } catch {}
}
try { atomicWriteFileSync(join(sessionDir, 'ralplan-state.json'), JSON.stringify(state, null, 2)); } catch {}
return;
}
const localDir = join(_omcRoot, 'state');
if (!existsSync(localDir)) {
try { mkdirSync(localDir, { recursive: true }); } catch {}
}
try { atomicWriteFileSync(join(localDir, 'ralplan-state.json'), JSON.stringify(state, null, 2)); } catch {}
}
/**
* Link ralph and team state files for composition.
* Updates both state files to reference each other.
*/
function linkRalphTeam(directory, sessionId, omcRoot) {
const _omcRoot = omcRoot;
const getStatePath = (modeName) => {
if (sessionId && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId)) {
return join(_omcRoot, 'state', 'sessions', sessionId, `${modeName}-state.json`);
}
return join(_omcRoot, 'state', `${modeName}-state.json`);
};
// Update ralph state with linked_team
try {
const ralphPath = getStatePath('ralph');
if (existsSync(ralphPath)) {
const state = JSON.parse(readFileSync(ralphPath, 'utf-8'));
state.linked_team = true;
writeFileSync(ralphPath, JSON.stringify(state, null, 2), { mode: 0o600 });
}
} catch { /* silent */ }
// Update team state with linked_ralph
try {
const teamPath = getStatePath('team');
if (existsSync(teamPath)) {
const state = JSON.parse(readFileSync(teamPath, 'utf-8'));
state.linked_ralph = true;
writeFileSync(teamPath, JSON.stringify(state, null, 2), { mode: 0o600 });
}
} catch { /* silent */ }
}
/**
* Check if the team feature is enabled in Claude Code settings.
* Reads settings.json from [$CLAUDE_CONFIG_DIR|~/.claude] and checks for
* CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS env var.
* @returns {boolean} true if team feature is enabled
*/
function isTeamEnabled() {
try {
// Check settings.json first (authoritative, user-controlled)
const cfgDir = getClaudeConfigDir();
const settingsPath = join(cfgDir, 'settings.json');
if (existsSync(settingsPath)) {
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
if (settings.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === '1' ||
settings.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === 'true') {
return true;
}
}
// Fallback: check env var (for dev/CI environments)
if (process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === '1' ||
process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === 'true') {
return true;
}
return false;
} catch {
return false;
}
}
/**
* Detect an installed AND enabled official Anthropic `ralph-loop` plugin
* that exposes a `/ralph-loop` command, without scanning any plugin payloads.
*
* Two independent signals, following existing installer semantics
* (`hasEnabledOmcPlugin` in src/installer/index.ts):
*
* 1. INSTALLED: the machine-readable plugin registry
* `[$CLAUDE_CONFIG_DIR|~/.claude]/plugins/installed_plugins.json` contains
* the official id `ralph-loop@claude-plugins-official` with a real
* `commands/ralph-loop.md` payload under its installPath. The registry's
* own `enabled` flag is deliberately NOT consulted: it does not
* authoritatively encode enablement (a plugin disabled through canonical
* settings can still carry `enabled: true`, and vice versa).
* 2. ENABLED: the official id is enabled by the effective Claude Code settings
* for the active project, resolved highest-precedence-first across
* `<project>/.claude/settings.local.json`, `<project>/.claude/settings.json`
* and `[$CLAUDE_CONFIG_DIR|~/.claude]/settings.json`. Within a file the
* canonical `enabledPlugins` field decides (legacy `plugins` field accepted
* for backward compatibility), as an array of plugin ids or a map whose
* value is not `false`. Missing or malformed settings are treated as not
* enabled, exactly like the installer.
*
* Both signals match the FULL plugin id exactly, marketplace suffix included.
* The suffix is never stripped, so a same-named community plugin
* (`ralph-loop@community`) can never stand in for the official one — not even
* when the official plugin is installed and explicitly disabled.
*
* No SKILL.md body, command body, or private payload is ever read.
*
* Returns a short notice string, or '' when the official plugin is not
* installed and enabled.
*/
const OFFICIAL_RALPH_LOOP_PLUGIN_ID = 'ralph-loop@claude-plugins-official';
function isOfficialRalphLoopPluginId(value) {
return typeof value === 'string'
&& value.trim().toLowerCase() === OFFICIAL_RALPH_LOOP_PLUGIN_ID;
}
/**
* Enablement verdict of a single settings field for the official plugin:
* `true`/`false` when the field mentions the official id, `null` when it does
* not mention it at all (so the next field may decide).
*/
function officialRalphLoopEnablementIn(field) {
if (Array.isArray(field)) {
return field.some((id) => isOfficialRalphLoopPluginId(id)) ? true : null;
}
if (field && typeof field === 'object') {
for (const [id, value] of Object.entries(field)) {
if (isOfficialRalphLoopPluginId(id)) return value !== false;
}
}
return null;
}
function officialRalphLoopEnablementInSettings(settings) {
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) return null;
// Canonical `enabledPlugins` wins outright when it mentions the official id;
// the legacy `plugins` field only decides when canonical is silent about it.
for (const field of [settings.enabledPlugins, settings.plugins]) {
const verdict = officialRalphLoopEnablementIn(field);
if (verdict !== null) return verdict;
}
return null;
}
/**
* A hook payload's cwd is caller-supplied. Only an absolute path is a usable
* project root; anything else would silently resolve a caller-controlled
* fragment against the hook process cwd, so it is rejected in favour of the
* hook's own cwd.
*/
function resolveSettingsProjectRoot(directory) {
return typeof directory === 'string' && directory.length > 0 && isAbsolute(directory)
? directory
: process.cwd();
}
/**
* Claude Code resolves `enabledPlugins` across settings scopes, so a project may
* enable a plugin the user scope never mentions, or disable one the user scope
* enables. Highest precedence first; the first scope that mentions the official
* id decides, scopes that never mention it are transparent, and a malformed
* file fails closed (no notice).
*/
function isOfficialRalphLoopEnabledForProject(directory) {
const projectRoot = resolveSettingsProjectRoot(directory);
const settingsPaths = [
join(projectRoot, '.claude', 'settings.local.json'),
join(projectRoot, '.claude', 'settings.json'),
join(getClaudeConfigDir(), 'settings.json'),
];
for (const settingsPath of settingsPaths) {
if (!existsSync(settingsPath)) continue;
let settings;
try {
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
} catch {
return false;
}
const verdict = officialRalphLoopEnablementInSettings(settings);
if (verdict !== null) return verdict;
}
return false;
}
function findOfficialRalphLoopNotice(directory) {
if (!isOfficialRalphLoopEnabledForProject(directory)) {
return '';
}
const installedPluginsPath = join(getClaudeConfigDir(), 'plugins', 'installed_plugins.json');
if (!existsSync(installedPluginsPath)) {
return '';
}
let registry;
try {
registry = JSON.parse(readFileSync(installedPluginsPath, 'utf-8'));
} catch {
return '';
}
if (!registry || typeof registry !== 'object' || Array.isArray(registry)) {
return '';
}
const plugins = registry.plugins ?? registry;
if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) {
return '';
}
const entries = plugins[OFFICIAL_RALPH_LOOP_PLUGIN_ID];
if (!Array.isArray(entries) || entries.length === 0) {
return '';
}
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const installPath = entry.installPath;
if (typeof installPath !== 'string' || installPath.length === 0) continue;
const commandFile = join(installPath, 'commands', 'ralph-loop.md');
if (existsSync(commandFile)) {
return 'Note: the official Anthropic `ralph-loop` plugin is also installed. `/ralph` runs OMC\'s ralph; use `/ralph-loop` for the official plugin.';
}
}
return '';
}
// Read the OMC JSONC config the way src/config/loader.ts does, inlined so the
// standalone hook stays build-independent (mirrors scripts/lib/agent-model-config.mjs).
function getOmcUserConfigDir() {
if (process.platform === 'win32') {
return process.env.APPDATA || join(homedir(), 'AppData', 'Roaming');
}
return process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
}
// Mirrors src/utils/jsonc.ts:stripJsoncComments (strips comments AND trailing commas)
function stripJsoncComments(content) {
return stripTrailingCommas(stripComments(content));
}
function stripComments(content) {
let result = '';
let i = 0;
while (i < content.length) {
if (content[i] === '/' && content[i + 1] === '/') {
while (i < content.length && content[i] !== '\n') i++;
continue;
}
if (content[i] === '/' && content[i + 1] === '*') {
i += 2;
while (i < content.length && !(content[i] === '*' && content[i + 1] === '/')) i++;
i += 2;
continue;
}
if (content[i] === '"') {
result += content[i++];
while (i < content.length && content[i] !== '"') {
if (content[i] === '\\') {
result += content[i++];
if (i < content.length) result += content[i++];
continue;
}
result += content[i++];
}
if (i < content.length) result += content[i++];
continue;
}
result += content[i++];
}
return result;
}
// Mirrors src/utils/jsonc.ts:stripTrailingCommas (comma before a closing } or ]).
function stripTrailingCommas(content) {
let result = '';
let i = 0;
while (i < content.length) {
if (content[i] === '"') {
result += content[i++];
while (i < content.length && content[i] !== '"') {
if (content[i] === '\\') {
result += content[i++];
if (i < content.length) result += content[i++];
continue;
}
result += content[i++];
}
if (i < content.length) result += content[i++];
continue;
}
if (content[i] === ',') {
let j = i + 1;
while (j < content.length && /\s/.test(content[j])) j++;
if (content[j] === '}' || content[j] === ']') {
i++;
continue;
}
}
result += content[i++];
}
return result;
}
function loadJsoncConfig(path) {
if (!existsSync(path)) return null;
try {
return JSON.parse(stripJsoncComments(readFileSync(path, 'utf-8')));
} catch {
return null;
}
}
/**
* Skills the user opted out of via `keywordDetector.disabled` in the OMC
* config: project `.claude/omc.jsonc` first, then user
* `~/.config/claude-omc/config.jsonc`, the same JSONC surface
* src/config/loader.ts reads. Empty when unset, so default behavior is
* unchanged. `cancel` is never disableable: it is the emergency stop.
* @param {string} directory project working directory
* @returns {Set<string>} disabled skill names (never includes 'cancel')
*/
function loadDisabledKeywords(directory) {
const configPaths = [
join(directory || process.cwd(), '.claude', 'omc.jsonc'),
join(getOmcUserConfigDir(), 'claude-omc', 'config.jsonc'),
];
for (const configPath of configPaths) {
const config = loadJsoncConfig(configPath);
const disabled = config?.keywordDetector?.disabled;
if (Array.isArray(disabled)) {
return new Set(disabled.filter((name) => name !== 'cancel'));
}
}
return new Set();
}
/**
* Create a compact skill invocation guide without inlining SKILL.md bodies.
* Full skill text remains available by path, avoiding UserPromptSubmit token blowups.
*/
function createSkillInvocation(skillName, originalPrompt, args = '', directory = '') {
const argsSection = args ? `
Arguments: ${args}` : '';
const skillPath = resolveSkillPath(skillName);
const pathStatus = existsSync(skillPath)
? `Read fallback: open ${skillPath} and follow its SKILL.md instructions.`
: `Read fallback: locate skills/${skillName}/SKILL.md in the active oh-my-claudecode plugin/install and follow it.`;
const ralphLoopNotice = skillName === 'ralph' ? findOfficialRalphLoopNotice(directory) : '';
return `[MAGIC KEYWORD: ${skillName.toUpperCase()}]
Skill routing detected: ${skillName}
Preferred invocation: /oh-my-claudecode:${skillName}${args ? ` ${args}` : ''}
${pathStatus}${argsSection}${ralphLoopNotice ? `
${ralphLoopNotice}` : ''}
User request (compact echo; original prompt remains authoritative):
${compactHookText(originalPrompt)}
IMPORTANT: Start the ${skillName} workflow immediately. If the slash invocation is unavailable, read the SKILL.md at the fallback path instead of relying on this compact guide.`;
}
/**
* Create multi-skill invocation message for combined keywords
*/
function createMultiSkillInvocation(skills, originalPrompt, directory = '') {
if (skills.length === 0) return '';
if (skills.length === 1) {
return createSkillInvocation(skills[0].name, originalPrompt, skills[0].args, directory);
}
const skillBlocks = skills.map((s, i) => {
const skillPath = resolveSkillPath(s.name);
const argsText = s.args ? ` ${s.args}` : '';
const pathStatus = existsSync(skillPath)
? `Read fallback: ${skillPath}`
: `Read fallback: locate skills/${s.name}/SKILL.md in the active oh-my-claudecode plugin/install`;
return `### Skill ${i + 1}: ${s.name.toUpperCase()}
Preferred invocation: /oh-my-claudecode:${s.name}${argsText}
${pathStatus}`;
}).join('\n\n');
// Multi-skill routing (e.g. `/ralph deep-interview`) must carry the same
// disambiguation notice as the single-skill path, or it becomes a bypass.
const ralphLoopNotice = skills.some((s) => s.name === 'ralph')
? findOfficialRalphLoopNotice(directory)
: '';
return `[MAGIC KEYWORDS DETECTED: ${skills.map(s => s.name.toUpperCase()).join(', ')}]
Execute ALL detected workflows in order using compact invocation guidance. Do not inline full SKILL.md files into the prompt.
${skillBlocks}${ralphLoopNotice ? `
${ralphLoopNotice}` : ''}
User request (compact echo; original prompt remains authoritative):
${compactHookText(originalPrompt)}
IMPORTANT: Complete ALL skills listed above in order. Start with the first skill IMMEDIATELY.`;
}
/**
* Resolve conflicts between detected keywords
*/
function resolveConflicts(matches) {
const names = matches.map(m => m.name);
// Cancel is exclusive
if (names.includes('cancel')) {
return [matches.find(m => m.name === 'cancel')];
}
let resolved = [...matches];
// Team keyword detection removed — team is now explicit-only via /team skill.
// Sort by priority order
const priorityOrder = ['cancel','ralph','ultragoal','autopilot','ralplan',
'deep-interview','ai-slop-cleaner','tdd','code-review','security-review','ultrathink','deepsearch','analyze'];
resolved.sort((a, b) => priorityOrder.indexOf(a.name) - priorityOrder.indexOf(b.name));
return resolved;
}
/**
* Create proper hook output with additionalContext (Claude Code hooks API)
* The 'message' field is NOT a valid hook output - use hookSpecificOutput.additionalContext
*/
function createHookOutput(additionalContext) {
return {
continue: true,
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
additionalContext
}
};
}
// Main
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' ||
process.env.DISABLE_OMC === 'true' ||
_skipHooks.includes('keyword-detector')
) {
console.log(JSON.stringify({ continue: true }));
return;
}
// Team worker guard: prevent keyword detection inside team workers to avoid
// infinite spawning loops (worker detects "team" -> invokes team skill -> spawns more workers)
if (process.env.OMC_TEAM_WORKER) {
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
return;
}
try {
const input = await readStdin();
if (!input.trim()) {
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
return;
}
let data = {};
try { data = JSON.parse(input); } catch {}
const directory = data.cwd || data.directory || process.cwd();
const sessionId = data.session_id || data.sessionId || '';
const omcRoot = await resolveOmcStateRoot(directory);
const prompt = extractPrompt(input);
if (!prompt) {
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
return;
}
// Named profiles are an explicit command surface. Handle them before generic
// autopilot keyword activation so only a complete, validated state is written.
const workflowInvocation = parseWorkflowInvocation(prompt);
if (workflowInvocation.kind === 'invalid-explicit-workflow-invocation') {
console.log(JSON.stringify(createHookOutput(
`[AUTOPILOT WORKFLOW ERROR] ${workflowInvocation.error} No autopilot state was activated.`
)));
return;
}
if (workflowInvocation.kind === 'valid') {
try {
if (!isWorkflowRuntimeSupported()) throw new Error('named autopilot workflow profiles require Linux with flock');
const resumedPrompt = resumeWorkflowProfile(directory, sessionId, workflowInvocation.workflowName, omcRoot);
if (resumedPrompt === false) throw new Error('workflow_descriptor_integrity_failed');
if (resumedPrompt) {
console.log(JSON.stringify(createHookOutput(resumedPrompt)));
return;
}
const workflow = selectWorkflowProfile(directory, workflowInvocation.workflowName);
const stagePrompt = activateWorkflowProfile(directory, sessionId, workflowInvocation.task, workflow, omcRoot, data.transcript_path || data.transcriptPath);
if (!stagePrompt) {
throw new Error('Could not persist workflow state.');
}
console.log(JSON.stringify(createHookOutput(stagePrompt)));
} catch (error) {
const message = error instanceof Error ? error.message : 'Workflow activation failed.';
console.log(JSON.stringify(createHookOutput(
`[AUTOPILOT WORKFLOW ERROR] ${message} No autopilot state was activated.`
)));
}
return;
}
if (isRetiredSlashInvocation(prompt)) {
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
return;
}
// `/ask <provider> ...` delegates the remainder of the prompt to an
// advisor process. Magic keywords inside that delegated payload must not
// activate modes in the current Claude Code session.
if (isExplicitAskSlashInvocation(prompt)) {
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
return;
}
if (isExplicitRalplanSlashInvocation(prompt)) {
activateRalplanStartupState(directory, prompt, sessionId, omcRoot);
console.log(JSON.stringify(createHookOutput(
`[RALPLAN INIT]\n` +
`Explicit /ralplan invoke detected during UserPromptSubmit.\n` +
`ralplan state has been initialized immediately and marked awaiting confirmation so the stop hook will not block this startup path.\n\n` +
createSkillInvocation('ralplan', prompt, '', directory)
)));
return;
}
// Strip pasted system-echo blocks BEFORE any mode-keyword dispatch runs.
// This covers both the hasActionableKeyword() call sites AND alternative
// matchers (e.g. isAntiSlopCleanupRequest) that bypass it. The helper
// inside hasActionableKeyword also strips defensively, which stays as
// belt-and-suspenders.
const cleanPrompt = stripSystemEchoes(
sanitizeForKeywordDetection(prompt).toLowerCase(),
);
// Collect all matching keywords
const matches = [];
// Cancel keywords
if (hasActionableKeyword(cleanPrompt, /\b(cancelomc|stopomc)\b/i)) {
matches.push({ name: 'cancel', args: '' });
}
// Ralph keywords
if (hasActionableRalphKeyword(cleanPrompt, /\b(ralph)\b|(랄프)(?!로렌)|(ラルフ)(?!・?ローレン)/i)) {
matches.push({ name: 'ralph', args: '' });
}
// Autopilot keywords
// "autonomous" intentionally excluded — it is too common in technical and
// research prose (e.g. "autonomous driving", "autonomous agent") to be a
// reliable trigger. Aligns with src/hooks/keyword-detector/index.ts and
// templates/hooks/keyword-detector.mjs, which already exclude it.
if (hasActionableKeyword(cleanPrompt, /\b(autopilot|auto pilot|auto-pilot|full auto|fullsend)\b|(오토파일럿)|(オートパイロット)/i) ||
hasActionableKeyword(cleanPrompt, /\b(build|create|make)\s+me\s+(an?\s+)?(app|feature|project|tool|plugin|website|api|server|cli|script|system|service|dashboard|bot|extension)\b/i) ||
hasActionableKeyword(cleanPrompt, /\bi\s+want\s+a\s+(app|feature|project|tool|plugin|website|api|server|cli|script|system|service|dashboard|bot|extension)\b/i) ||
hasActionableKeyword(cleanPrompt, /\bi\s+want\s+an\s+(app|feature|project|tool|plugin|website|api|server|cli|script|system|service|dashboard|bot|extension)\b/i) ||
hasActionableKeyword(cleanPrompt, /\bhandle\s+it\s+all\b/i) ||
hasActionableKeyword(cleanPrompt, /\bend\s+to\s+end\b/i) ||
hasActionableKeyword(cleanPrompt, /\be2e\s+this\b/i)) {
matches.push({ name: 'autopilot', args: '' });
}
// Ultragoal keywords
if (hasExplicitActionableKeyword(cleanPrompt, /\b(ultragoal)\b/i)) {
matches.push({ name: 'ultragoal', args: '' });
}
// Ultrapilot keywords removed — routed to team which is now explicit-only (/team).
// Team keyword detection removed — team mode is now explicit-only via /team skill.
// This prevents infinite spawning when Claude workers receive prompts containing "team".
// Ralplan keyword
if (hasActionableRalplanKeyword(cleanPrompt, /\b(ralplan)\b|(랄플랜)|(ラルプラン)/i)) {
matches.push({ name: 'ralplan', args: '' });
}
// Deep interview keywords
if (hasActionableKeyword(cleanPrompt, /\b(deep[\s-]interview|ouroboros)\b|(딥인터뷰)|(ディープインタビュー)/i)) {
matches.push({ name: 'deep-interview', args: '' });
}
// AI slop cleanup keywords
if (isAntiSlopCleanupRequest(cleanPrompt)) {
matches.push({ name: 'ai-slop-cleaner', args: '' });
}
// TDD keywords
if (hasActionableKeyword(cleanPrompt, /\b(tdd)\b|(테스트\s?퍼스트)|(テスト\s?ファースト)/i) ||
hasActionableKeyword(cleanPrompt, /\btest\s+first\b/i) ||
hasActionableKeyword(cleanPrompt, /\bred\s+green\b/i)) {
matches.push({ name: 'tdd', args: '' });
}
// Code review keywords — skip when the prompt is echoed review-instruction text
if (!isReviewSeedContext(cleanPrompt) &&
hasActionableKeyword(cleanPrompt, /\b(code\s+review|review\s+code)\b|(코드\s?리뷰)(?!어)|(コード\s?レビュー)(?!ア)/i)) {
matches.push({ name: 'code-review', args: '' });
}
// Security review keywords — skip when the prompt is echoed review-instruction text
if (!isReviewSeedContext(cleanPrompt) &&
hasActionableKeyword(cleanPrompt, /\b(security\s+review|review\s+security)\b|(보안\s?리뷰)(?!어)|(セキュリティ[ー]?\s?レビュー)(?!ア)/i)) {
matches.push({ name: 'security-review', args: '' });
}
// Ultrathink keywords
if (hasActionableKeyword(cleanPrompt, /\b(ultrathink|think hard|think deeply)\b|(울트라씽크)|(ウルトラシンク)/i)) {
matches.push({ name: 'ultrathink', args: '' });
}
// Deepsearch keywords
if (hasActionableKeyword(cleanPrompt, /\b(deepsearch)\b|(딥\s?서치)|(ディープ\s?サーチ)/i) ||
hasActionableKeyword(cleanPrompt, /\bsearch\s+(the\s+)?(codebase|code|files?|project)\b/i) ||
hasActionableKeyword(cleanPrompt, /\bfind\s+(in\s+)?(codebase|code|all\s+files?)\b/i)) {
matches.push({ name: 'deepsearch', args: '' });
}
// Analyze keywords
if (hasActionableKeyword(cleanPrompt, /\b(deep[\s-]?analyze|deepanalyze)\b|(딥\s?분석)|(ディープ\s?アナライズ)/i)) {
matches.push({ name: 'analyze', args: '' });
}
// Wiki keywords
if (hasActionableKeyword(cleanPrompt, /\b(wiki(?:\s+(?:this|add|lint|query))?)\b/i)) {
matches.push({ name: 'wiki', args: '' });
}
// Drop user-disabled keywords, then dedupe before conflict resolution.
// This chokepoint covers both magic-keyword and mode-injection keywords.
const disabledKeywords = loadDisabledKeywords(directory);
const seen = new Set();
const uniqueMatches = [];
for (const m of matches) {
if (disabledKeywords.has(m.name)) {
continue;
}
if (!seen.has(m.name)) {
seen.add(m.name);
uniqueMatches.push(m);
}
}
// Resolve conflicts
const resolved = resolveConflicts(uniqueMatches);
// Import flow tracer once (best-effort)
let tracer = null;
try { tracer = await import('../dist/hooks/subagent-tracker/flow-tracer.js'); } catch { /* silent */ }
// Import follow-up planner modules (best-effort — requires npm run build)
let followupPlanner = null;
let planningArtifacts = null;
try {
followupPlanner = await import('../dist/team/followup-planner.js');
planningArtifacts = await import('../dist/planning/artifacts.js');
} catch { /* silent — dist/ may not exist yet */ }
// Check for approved follow-up shortcut: bypass ralplan gate when a prior ralplan
// cycle completed and left an approved plan with a launch hint.
if (followupPlanner && planningArtifacts) {
// Detect if ralplan state exists (was recently active) — serves as "prior skill = ralplan" signal
const ralplanStatePaths = sessionId && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/.test(sessionId)
? [
join(omcRoot, 'state', 'sessions', sessionId, 'ralplan-state.json'),
join(directory, '.omx', 'state', 'sessions', sessionId, 'ralplan-state.json'),
]
: [
join(omcRoot, 'state', 'ralplan-state.json'),
join(directory, '.omx', 'state', 'ralplan-state.json'),
];
const ralplanState = readRalplanFollowupState(ralplanStatePaths, sessionId);
const ralplanTerminal = isRalplanFollowupTerminal(ralplanState);
if (ralplanState) {
const artifacts = planningArtifacts.readPlanningArtifacts(directory);
const planningComplete = planningArtifacts.isPlanningComplete(artifacts);
const teamHint = planningArtifacts.readApprovedExecutionLaunchHint?.(directory, 'team');
const ralphHint = planningArtifacts.readApprovedExecutionLaunchHint?.(directory, 'ralph');
const isTeamFollowup = followupPlanner.isApprovedExecutionFollowupShortcut('team', prompt, {
planningComplete,
priorSkill: 'ralplan',
ralplanTerminal,
approvedExecutionLaunchHint: Boolean(teamHint),
});
const isRalphFollowup = followupPlanner.isApprovedExecutionFollowupShortcut('ralph', prompt, {
planningComplete,
priorSkill: 'ralplan',
ralplanTerminal,
approvedExecutionLaunchHint: Boolean(ralphHint),
});
if (isTeamFollowup) {
console.log(JSON.stringify(createHookOutput(createSkillInvocation('team', prompt, '', directory))));
return;
}
if (isRalphFollowup) {
console.log(JSON.stringify(createHookOutput(createSkillInvocation('ralph', prompt, '', directory))));
return;
}
const isShortTeamRequest = followupPlanner.isShortTeamFollowupRequest?.(prompt) === true;
const isShortRalphRequest = followupPlanner.isShortRalphFollowupRequest?.(prompt) === true;
if (isShortTeamRequest || isShortRalphRequest) {
// A compact continuation can echo the saved plan and a terse lane hint.
// Unless the ralplan state is terminal and a launch hint is present,
// keep ralplan read-only instead of falling through to generic ralph.
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
return;
}
}
}
// Nothing left to act on (no keywords, or all opted out) - pass through.
// Keep this after approved follow-up handling so short post-ralplan
// prompts like "team" can launch the approved execution path even
// though generic team keyword auto-detection is disabled.
if (resolved.length === 0) {
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
return;
}
// Record detected keywords to flow trace
if (tracer) {
for (const match of resolved) {
try { tracer.recordKeywordDetected(directory, sessionId, match.name); } catch { /* silent */ }
}
}
// Route cancellation without mutating state; the cancel workflow commits the primary mode first.
if (resolved.length > 0 && resolved[0].name === 'cancel') {
console.log(JSON.stringify(createHookOutput(createSkillInvocation('cancel', prompt, '', directory))));
return;
}
// Activate states for modes that need them (team removed — explicit-only via /team skill)
const stateModes = resolved.filter(m => ['ralph', 'ultragoal', 'autopilot', 'ralplan'].includes(m.name));
for (const mode of stateModes) {
const activationError = await activateState(directory, prompt, mode.name, sessionId);
if (activationError === 'workflow_descriptor_integrity_failed') {
console.log(JSON.stringify(createHookOutput('workflow_descriptor_integrity_failed')));
return;
}
}
// Record mode changes to flow trace
if (tracer) {
for (const mode of stateModes) {
try { tracer.recordModeChange(directory, sessionId, 'none', mode.name); } catch { /* silent */ }
}
}
const additionalContextParts = [];
for (const [keywordName, message] of MODE_MESSAGE_KEYWORDS) {
const index = resolved.findIndex(m => m.name === keywordName);
if (index !== -1) {
resolved.splice(index, 1);
additionalContextParts.push(message);
}
}
if (resolved.length === 0 && additionalContextParts.length > 0) {
console.log(JSON.stringify(createHookOutput(additionalContextParts.join(''))));
return;
}
if (resolved.length > 0) {
additionalContextParts.push(createMultiSkillInvocation(resolved, prompt, directory));
}
if (additionalContextParts.length > 0) {
console.log(JSON.stringify(createHookOutput(additionalContextParts.join(''))));
return;
}
} catch (error) {
// On any error, allow continuation
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
}
}
main();