项目文件夹

文件
wehub-resource-sync adc62957a7
CI / Multi-repo Path Gate (AST-grep) (windows-latest) (push) Has been cancelled
CI / Version Consistency Check (push) Has been cancelled
CI / npm pack + install test (push) Has been cancelled
CI / Lint & Type Check (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Test (Windows path suite) (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / No Committed Build Artifacts (push) Has been cancelled
CI / Multi-repo Path Gate (AST-grep) (ubuntu-latest) (push) Has been cancelled
Upgrade Test / omc update + session-start hook (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:36:54 +08:00

1163 行
40 KiB
JavaScript

#!/usr/bin/env node
/**
* PostToolUse Hook: Verification Reminder System (Node.js)
* Monitors tool execution and provides contextual guidance
* Cross-platform: Windows, macOS, Linux
*/
import { execSync } from 'child_process';
import { createHash } from 'crypto';
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, renameSync, unlinkSync } from 'fs';
import { closeSync, openSync, readSync, statSync } from 'fs';
import { basename, join, dirname, resolve } from 'path';
import { homedir, tmpdir } from 'os';
import { fileURLToPath, pathToFileURL } from 'url';
import { getClaudeConfigDir } from './lib/config-dir.mjs';
import { encodeProjectPath } from './lib/encode-project-path.mjs';
import { resolveOmcStateRoot } from './lib/state-root.mjs';
import { readStdin } from './lib/stdin.mjs';
const AGENT_OUTPUT_ANALYSIS_LIMIT = parseInt(process.env.OMC_AGENT_OUTPUT_ANALYSIS_LIMIT || '12000', 10);
const AGENT_OUTPUT_SUMMARY_LIMIT = parseInt(process.env.OMC_AGENT_OUTPUT_SUMMARY_LIMIT || '360', 10);
const PREEMPTIVE_WARNING_THRESHOLD_PERCENT = parseInt(process.env.OMC_PREEMPTIVE_COMPACTION_WARNING_PERCENT || '70', 10);
const PREEMPTIVE_CRITICAL_THRESHOLD_PERCENT = parseInt(process.env.OMC_PREEMPTIVE_COMPACTION_CRITICAL_PERCENT || '90', 10);
const PREEMPTIVE_COOLDOWN_MS = parseInt(process.env.OMC_PREEMPTIVE_COMPACTION_COOLDOWN_MS || '60000', 10);
const PREEMPTIVE_TRANSCRIPT_TAIL_BYTES = 4096;
const PREEMPTIVE_LARGE_OUTPUT_TOOLS = new Set(['read', 'grep', 'glob', 'bash', 'webfetch', 'task', 'taskcreate', 'taskupdate', 'taskoutput']);
const QUIET_LEVEL = getQuietLevel();
const SESSION_ID_ALLOWLIST = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,255}$/;
function getQuietLevel() {
const parsed = Number.parseInt(process.env.OMC_QUIET || '0', 10);
if (Number.isNaN(parsed)) return 0;
return Math.max(0, parsed);
}
/**
* Resolve the .omc root directory for a given starting directory.
*
* Resolution order (mirrors src/lib/worktree-paths.ts getOmcRoot):
* 1) OMC_STATE_DIR env — log a warning and fall through (full project-id
* derivation lives in the TS layer; .mjs scripts use resolveOmcStateRoot
* for the async TS-backed path when they need OMC_STATE_DIR honoring).
* 2) Walk up from startDir looking for a .omc-workspace marker file.
* The first directory containing that file is the workspace anchor.
* 3) git rev-parse --show-toplevel from startDir.
* 4) Fallback to startDir itself.
*
* @param {string} startDir - Directory to resolve from (usually cwd from hook payload)
* @returns {string} Absolute path to the .omc root directory
*/
function resolveOmcRoot(startDir) {
const dir = startDir || process.cwd();
// 1) OMC_STATE_DIR: full project-id derivation is TS-only; warn and fall through.
if (process.env.OMC_STATE_DIR) {
process.stderr.write(
'[omc] OMC_STATE_DIR is set; resolveOmcRoot() falling through to workspace-marker ' +
'resolution. Use resolveOmcStateRoot() for full OMC_STATE_DIR support.\n'
);
}
// 2) Walk up looking for .omc-workspace marker
try {
let cursor = resolve(dir);
const home = (() => { try { return resolve(homedir()); } catch { return null; } })();
while (true) {
if (existsSync(join(cursor, '.omc-workspace'))) {
return join(cursor, '.omc');
}
const parent = dirname(cursor);
if (parent === cursor) break;
if (home && cursor === home) break;
cursor = parent;
}
} catch {
// walk failed — continue to git fallback
}
// 3) git rev-parse --show-toplevel
try {
const top = execSync('git rev-parse --show-toplevel', {
cwd: dir,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000,
windowsHide: true,
}).trim();
if (top) return join(top, '.omc');
} catch {
// not in a git repo — fall through
}
// 4) Fallback to startDir
return join(dir, '.omc');
}
function clampPercent(percent, fallback) {
if (!Number.isFinite(percent)) return fallback;
return Math.min(100, Math.max(1, percent));
}
function getPreemptiveWarningThreshold() {
return clampPercent(PREEMPTIVE_WARNING_THRESHOLD_PERCENT, 70);
}
function getPreemptiveCriticalThreshold() {
return clampPercent(PREEMPTIVE_CRITICAL_THRESHOLD_PERCENT, 90);
}
// Get the directory of this script to resolve the dist module
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const distDir = join(__dirname, '..', 'dist', 'hooks', 'notepad');
// Try to import notepad functions (may fail if not built)
let setPriorityContext = null;
let addWorkingMemoryEntry = null;
try {
const notepadModule = await import(pathToFileURL(join(distDir, 'index.js')).href);
setPriorityContext = notepadModule.setPriorityContext;
addWorkingMemoryEntry = notepadModule.addWorkingMemoryEntry;
} catch {
// Notepad module not available - remember tags will be silently ignored
}
// Debug logging helper - gated behind OMC_DEBUG env var
const debugLog = (...args) => {
if (process.env.OMC_DEBUG) console.error('[omc:debug:post-tool-verifier]', ...args);
};
// State file for session tracking
const cfgDir = getClaudeConfigDir();
const STATE_FILE = join(cfgDir, '.session-stats.json');
// Ensure state directory exists
try {
const stateDir = cfgDir;
if (!existsSync(stateDir)) {
mkdirSync(stateDir, { recursive: true });
}
} catch {}
// Load session statistics
function loadStats() {
try {
if (existsSync(STATE_FILE)) {
return JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
}
} catch (e) {
debugLog('Failed to load stats:', e.message);
}
return { sessions: {} };
}
// Save session statistics
function saveStats(stats) {
const tmpFile = `${STATE_FILE}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
try {
writeFileSync(tmpFile, JSON.stringify(stats, null, 2));
renameSync(tmpFile, STATE_FILE);
} catch (e) {
debugLog('Failed to save stats:', e.message);
try { unlinkSync(tmpFile); } catch {}
}
}
// Update stats for this session
function updateStats(toolName, sessionId) {
const stats = loadStats();
if (!stats.sessions[sessionId]) {
stats.sessions[sessionId] = {
tool_counts: {},
last_tool: '',
total_calls: 0,
started_at: Math.floor(Date.now() / 1000)
};
}
const session = stats.sessions[sessionId];
session.tool_counts[toolName] = (session.tool_counts[toolName] || 0) + 1;
session.last_tool = toolName;
session.total_calls = (session.total_calls || 0) + 1;
session.updated_at = Math.floor(Date.now() / 1000);
saveStats(stats);
return session.tool_counts[toolName];
}
// Read bash history config (default: enabled)
function getBashHistoryConfig() {
try {
const configPath = join(cfgDir, '.omc-config.json');
if (existsSync(configPath)) {
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
if (config.bashHistory === false) return false;
if (typeof config.bashHistory === 'object' && config.bashHistory.enabled === false) return false;
}
} catch {}
return true; // Default: enabled
}
// Append command to ~/.bash_history (Unix only - no bash_history on Windows)
function appendToBashHistory(command) {
if (process.platform === 'win32') return;
if (!command || typeof command !== 'string') return;
// Clean command: trim, skip empty, skip if it's just whitespace
const cleaned = command.trim();
if (!cleaned) return;
// Skip internal/meta commands that aren't useful in history
if (cleaned.startsWith('#')) return;
try {
const historyPath = join(homedir(), '.bash_history');
appendFileSync(historyPath, cleaned + '\n');
} catch {
// Silently fail - history is best-effort
}
}
// Pattern to match Claude Code temp CWD permission errors (false positives on macOS)
// e.g. "zsh:1: permission denied: /var/folders/.../T/claude-abc123-cwd"
const CLAUDE_TEMP_CWD_PATTERN = /zsh:\d+: permission denied:.*\/T\/claude-[a-z0-9]+-cwd/gi;
// Strip Claude Code temp CWD noise before pattern matching
function stripClaudeTempCwdErrors(output) {
return output.replace(CLAUDE_TEMP_CWD_PATTERN, '');
}
// Pattern matching Claude Code's "Error: Exit code N" prefix line
// Note: no /g flag — module-level regex with /g is stateful (.lastIndex persists across calls)
const CLAUDE_EXIT_CODE_PREFIX = /^Error: Exit code \d+\s*$/m;
const QUOTED_SPAN_PATTERN =
/"[^"\n]{1,400}"|'[^'\n]{1,400}'|“[^”\n]{1,400}”|‘[^’\n]{1,400}’/g;
const NON_ACTIONABLE_ERROR_LINES = [
/^\s*["']?severity["']?\s*[:=]\s*["']error["']?\s*[,}]?\s*$/i,
/^\s*["']?totalErrors["']?\s*[:=]\s*0\b.*$/i,
/^\s*totalErrors\s*[:=]\s*0\b.*$/i,
/^\s*["']?error["']?\s*:\s*["'][^"']*["']\s*[,}]?\s*$/i,
/^\s*return\s*\{[^\n]*\berror\s*:\s*["'][^"']*["'][^\n]*\}\s*;?$/i,
];
function stripQuotedSpans(output) {
return output.replace(QUOTED_SPAN_PATTERN, ' ');
}
function isPytestRunOutput(output) {
if (!output) return false;
const cleaned = stripClaudeTempCwdErrors(output);
const hasPytestHeader =
/(^|\n)=+\s*test session starts\s*=+/i.test(cleaned) ||
/(^|\n).*pytest-\d/i.test(cleaned) ||
/(^|\n)collected\s+\d+\s+items?\b/i.test(cleaned);
const hasPytestBody =
/(^|\n)=+\s*short test summary info\s*=+/i.test(cleaned) ||
/(^|\n)=+\s*failures\s*=+/i.test(cleaned) ||
/(^|\n)(?:FAILED|ERROR)\s+.+::.+/m.test(cleaned) ||
/(^|\n)\S+\.py::\S+\s+(?:PASSED|FAILED|ERROR)\b/m.test(cleaned);
return hasPytestHeader && hasPytestBody;
}
function stripNonActionableErrorContext(output) {
if (!output) return '';
return output
.split('\n')
.filter((line) => {
const trimmed = line.trim();
if (!trimmed) return true;
return !NON_ACTIONABLE_ERROR_LINES.some((pattern) => pattern.test(trimmed));
})
.join('\n');
}
/**
* Detect non-zero exit code with valid stdout (issue #960).
* Returns true when output has Claude Code's "Error: Exit code N" prefix
* AND substantial content that doesn't itself indicate real errors.
* Example: `gh pr checks` exits 8 (pending) but outputs valid CI status.
*/
export function isNonZeroExitWithOutput(output) {
if (!output) return false;
const cleaned = stripNonActionableErrorContext(stripClaudeTempCwdErrors(output));
// Must contain Claude Code's exit code prefix
if (!CLAUDE_EXIT_CODE_PREFIX.test(cleaned)) return false;
// Strip exit code prefix line(s) and check remaining content
const remaining = cleaned.replace(CLAUDE_EXIT_CODE_PREFIX, '').trim();
// Must have at least one non-empty line of real output
const contentLines = remaining.split('\n').filter(l => l.trim().length > 0);
if (contentLines.length === 0) return false;
// If remaining content has its own error indicators, it's a real failure
const contentErrorPatterns = [
/error:/i,
/failed/i,
/cannot/i,
/permission denied/i,
/command not found/i,
/no such file/i,
/fatal:/i,
/abort/i,
];
return !contentErrorPatterns.some(p => p.test(stripQuotedSpans(remaining)));
}
// Detect failures in Bash output
export function detectBashFailure(output) {
if (!output) return false;
const cleaned = stripClaudeTempCwdErrors(output);
if (isPytestRunOutput(cleaned)) {
return false;
}
const explicitExitPatterns = [
/(^|\n)Error: Exit code [1-9]\d*(\n|$)/i,
/(^|\n).*\bexit code:\s*[1-9]\d*\b/i,
/(^|\n).*\bexit status\s+[1-9]\d*\b/i,
];
if (explicitExitPatterns.some(pattern => pattern.test(cleaned))) {
return true;
}
const linePatterns = [
/^error:\s+/i,
/^(?:bash|zsh|sh): .*command not found/i,
/^(?:bash|zsh|sh): .*no such file/i,
/^(?:bash|zsh|sh): .*permission denied/i,
/^(?:rm|cp|mv|cat|chmod|chown|git|node|npm|pnpm|yarn|python|python3|pip|pip3|cargo|go|rustc|docker|ffmpeg): .*permission denied/i,
/^(?:rm|cp|mv|cat|git|node|npm|pnpm|yarn|python|python3|pip|pip3|cargo|go|rustc|docker|ffmpeg): .*no such file/i,
/^fatal:\s+/i,
/^abort(?:ed)?\b/i,
/^(?:build|command|task|operation) failed\b/i,
];
return cleaned
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.some(line => linePatterns.some(pattern => pattern.test(line)));
}
// Detect background operation
function detectBackgroundOperation(output) {
const bgPatterns = [
/started/i,
/running/i,
/background/i,
/async/i,
/task_id/i,
/spawned/i,
];
return bgPatterns.some(pattern => pattern.test(output));
}
function resolveTranscriptPath(transcriptPath, cwd) {
if (!transcriptPath) return undefined;
try {
if (existsSync(transcriptPath)) return transcriptPath;
} catch {}
const worktreePattern = /--claude-worktrees-[^/\\]+/;
if (worktreePattern.test(transcriptPath)) {
const resolvedPath = transcriptPath.replace(worktreePattern, '');
try {
if (existsSync(resolvedPath)) return resolvedPath;
} catch {}
}
const effectiveCwd = cwd || process.cwd();
try {
const gitCommonDir = execSync('git rev-parse --git-common-dir', {
cwd: effectiveCwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
}).trim();
const mainRepoRoot = dirname(resolve(effectiveCwd, gitCommonDir));
const worktreeTop = execSync('git rev-parse --show-toplevel', {
cwd: effectiveCwd,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
}).trim();
if (mainRepoRoot !== worktreeTop) {
const sessionFile = basename(transcriptPath);
if (sessionFile) {
const projectsDir = join(getClaudeConfigDir(), 'projects');
if (existsSync(projectsDir)) {
const encodedMain = encodeProjectPath(mainRepoRoot);
const resolvedPath = join(projectsDir, encodedMain, sessionFile);
if (existsSync(resolvedPath)) return resolvedPath;
}
}
}
} catch {}
return transcriptPath;
}
function readTranscriptUsage(transcriptPath) {
if (!transcriptPath) return null;
let fd = -1;
try {
const stat = statSync(transcriptPath);
if (stat.size === 0) return null;
fd = openSync(transcriptPath, 'r');
const readSize = Math.min(PREEMPTIVE_TRANSCRIPT_TAIL_BYTES, stat.size);
const buffer = Buffer.alloc(readSize);
readSync(fd, buffer, 0, readSize, stat.size - readSize);
closeSync(fd);
fd = -1;
const tail = buffer.toString('utf-8');
const windowMatches = tail.match(/"context_window"\s{0,5}:\s{0,5}(\d+)/g);
const inputMatches = tail.match(/"input_tokens"\s{0,5}:\s{0,5}(\d+)/g);
if (!windowMatches || !inputMatches) return null;
const lastWindow = Number.parseInt(
windowMatches[windowMatches.length - 1].match(/(\d+)/)?.[1] || '0',
10,
);
const lastInput = Number.parseInt(
inputMatches[inputMatches.length - 1].match(/(\d+)/)?.[1] || '0',
10,
);
if (!Number.isFinite(lastWindow) || lastWindow <= 0) return null;
if (!Number.isFinite(lastInput) || lastInput < 0) return null;
return Math.round((lastInput / lastWindow) * 100);
} catch {
return null;
} finally {
if (fd !== -1) {
try { closeSync(fd); } catch {}
}
}
}
function readContextUsageFromHookInput(data) {
const contextWindow = data?.context_window;
if (!contextWindow || typeof contextWindow !== 'object') {
return null;
}
const usedPercentage = contextWindow.used_percentage;
if (Number.isFinite(usedPercentage) && usedPercentage >= 0) {
return Math.min(100, Math.max(0, Math.round(usedPercentage)));
}
const size = contextWindow.context_window_size;
if (!Number.isFinite(size) || size <= 0) {
return null;
}
const usage = contextWindow.current_usage;
if (!usage || typeof usage !== 'object') {
return null;
}
const inputTokens = Number(usage.input_tokens || 0);
const cacheCreationTokens = Number(usage.cache_creation_input_tokens || 0);
const cacheReadTokens = Number(usage.cache_read_input_tokens || 0);
const totalTokens = inputTokens + cacheCreationTokens + cacheReadTokens;
if (!Number.isFinite(totalTokens) || totalTokens < 0) {
return null;
}
return Math.min(100, Math.max(0, Math.round((totalTokens / size) * 100)));
}
function getPreemptiveCooldownFilePath(directory, sessionId) {
const cooldownScope =
sessionId && sessionId !== 'unknown'
? `${directory || process.cwd()}::${sessionId}`
: directory || process.cwd();
const hash = createHash('sha1').update(cooldownScope).digest('hex');
const cooldownDir = join(tmpdir(), 'omc-preemptive-compaction');
mkdirSync(cooldownDir, { recursive: true });
return join(cooldownDir, `${hash}.json`);
}
function readPreemptiveCooldownState(directory, sessionId) {
try {
const filePath = getPreemptiveCooldownFilePath(directory, sessionId);
if (!existsSync(filePath)) return null;
const data = JSON.parse(readFileSync(filePath, 'utf-8'));
if (!data || typeof data !== 'object') return null;
return {
lastWarningTime:
typeof data.lastWarningTime === 'number' ? data.lastWarningTime : 0,
severity: data.severity === 'critical' ? 'critical' : 'warning',
};
} catch {
return null;
}
}
function writePreemptiveCooldownState(directory, sessionId, severity, now) {
writeFileSync(
getPreemptiveCooldownFilePath(directory, sessionId),
JSON.stringify({ lastWarningTime: now, severity }),
{ mode: 0o600 },
);
}
function shouldSuppressPreemptiveWarning(directory, sessionId, severity, now) {
const cooldownState = readPreemptiveCooldownState(directory, sessionId);
if (!cooldownState) return false;
if (now - cooldownState.lastWarningTime >= PREEMPTIVE_COOLDOWN_MS) return false;
return !(cooldownState.severity === 'warning' && severity === 'critical');
}
function buildPreemptiveContextMessage(percentUsed, severity) {
if (severity === 'critical') {
return `[OMC CRITICAL] Context at ${percentUsed}% (critical threshold: ${getPreemptiveCriticalThreshold()}%). Run /compact now before continuing with more tools or agent fan-out.`;
}
return `[OMC WARNING] Context at ${percentUsed}% (warning threshold: ${getPreemptiveWarningThreshold()}%). Plan a /compact soon to preserve room for the next large tool output.`;
}
function maybeBuildPreemptiveCompactionMessage(toolName, data, directory) {
if (!PREEMPTIVE_LARGE_OUTPUT_TOOLS.has(String(toolName || '').toLowerCase())) {
return '';
}
const percentFromTranscript = readTranscriptUsage(
resolveTranscriptPath(data.transcript_path || data.transcriptPath, directory),
);
const percentUsed =
percentFromTranscript ?? readContextUsageFromHookInput(data);
const warningThreshold = getPreemptiveWarningThreshold();
const criticalThreshold = getPreemptiveCriticalThreshold();
if (percentUsed === null || percentUsed < warningThreshold) {
return '';
}
const severity = percentUsed >= criticalThreshold ? 'critical' : 'warning';
const now = Date.now();
const sessionId = data.session_id || data.sessionId || 'unknown';
if (shouldSuppressPreemptiveWarning(directory, sessionId, severity, now)) {
return '';
}
writePreemptiveCooldownState(directory, sessionId, severity, now);
return buildPreemptiveContextMessage(percentUsed, severity);
}
function getInvokedSkillName(toolInput) {
if (!toolInput || typeof toolInput !== 'object') return null;
const rawSkill =
toolInput.skill ||
toolInput.skill_name ||
toolInput.skillName ||
toolInput.command ||
null;
if (typeof rawSkill !== 'string' || !rawSkill.trim()) return null;
const normalized = rawSkill.trim();
return normalized.includes(':')
? normalized.split(':').at(-1).toLowerCase()
: normalized.toLowerCase();
}
function getSkillInvocationArgs(toolInput) {
if (!toolInput || typeof toolInput !== 'object') return '';
const candidates = [
toolInput.args,
toolInput.arguments,
toolInput.argument,
toolInput.skill_args,
toolInput.skillArgs,
toolInput.prompt,
toolInput.description,
toolInput.input,
];
return candidates.find(value => typeof value === 'string' && value.trim().length > 0)?.trim() || '';
}
function isConsensusPlanningSkillInvocation(skillName, toolInput) {
if (!skillName) return false;
if (skillName === 'ralplan') return true;
if (skillName !== 'plan' && skillName !== 'omc-plan') return false;
return getSkillInvocationArgs(toolInput).toLowerCase().includes('--consensus');
}
function getSkillActiveStatePaths(directory, sessionId) {
const stateDir = join(resolveOmcRoot(directory), 'state');
const safeSessionId = sessionId && SESSION_ID_ALLOWLIST.test(sessionId) ? sessionId : '';
return [
safeSessionId ? join(stateDir, 'sessions', safeSessionId, 'skill-active-state.json') : null,
join(stateDir, 'skill-active-state.json'),
].filter(Boolean);
}
function readSkillActiveState(directory, sessionId) {
for (const statePath of getSkillActiveStatePaths(directory, sessionId)) {
try {
if (!existsSync(statePath)) continue;
const state = JSON.parse(readFileSync(statePath, 'utf-8'));
if (state && typeof state === 'object') return state;
} catch {
// Ignore malformed or unreadable state; cleanup remains best-effort
}
}
return null;
}
function clearSkillActiveState(directory, sessionId) {
for (const statePath of getSkillActiveStatePaths(directory, sessionId)) {
try {
unlinkSync(statePath);
} catch {
// Best-effort cleanup; never fail the hook
}
}
}
function getRalplanStatePaths(directory, sessionId) {
const stateDir = join(resolveOmcRoot(directory), 'state');
const safeSessionId = sessionId && SESSION_ID_ALLOWLIST.test(sessionId) ? sessionId : '';
return [
safeSessionId ? join(stateDir, 'sessions', safeSessionId, 'ralplan-state.json') : null,
join(stateDir, 'ralplan-state.json'),
].filter(Boolean);
}
function deactivateRalplanState(directory, sessionId) {
const safeSessionId = sessionId && SESSION_ID_ALLOWLIST.test(sessionId) ? sessionId : '';
const terminalPhases = new Set(['complete', 'completed', 'failed', 'cancelled', 'done']);
const now = new Date().toISOString();
for (const statePath of getRalplanStatePaths(directory, sessionId)) {
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;
}
const currentPhase = typeof state.current_phase === 'string' ? state.current_phase : '';
const nextPhase = terminalPhases.has(currentPhase.toLowerCase()) ? currentPhase : 'complete';
writeFileSync(
statePath,
JSON.stringify(
{
...state,
active: false,
current_phase: nextPhase,
completed_at: typeof state.completed_at === 'string' ? state.completed_at : now,
deactivated_reason:
typeof state.deactivated_reason === 'string'
? state.deactivated_reason
: 'skill_completed',
},
null,
2,
),
);
} catch {
// Best-effort cleanup; never fail the hook
}
}
}
export function summarizeAgentResult(output, maxChars = AGENT_OUTPUT_SUMMARY_LIMIT) {
if (!output || typeof output !== 'string') return '';
const normalized = output
.replace(/\r/g, '')
.split('\n')
.map(l => l.trim())
.filter(Boolean)
.slice(0, 6)
.join(' | ');
if (!normalized) return '';
if (normalized.length <= maxChars) return normalized;
return `${normalized.slice(0, Math.max(0, maxChars - 20)).trimEnd()} … [truncated]`;
}
function clipToolOutputForAnalysis(toolName, output) {
if (typeof output !== 'string') return { clipped: '', wasTruncated: false };
const isAgentResultTool = toolName === 'Task' || toolName === 'TaskCreate' || toolName === 'TaskUpdate' || toolName === 'TaskOutput';
if (!isAgentResultTool || output.length <= AGENT_OUTPUT_ANALYSIS_LIMIT) {
return { clipped: output, wasTruncated: false };
}
return {
clipped: `${output.slice(0, AGENT_OUTPUT_ANALYSIS_LIMIT)}\n...[agent output truncated by OMC context guard]`,
wasTruncated: true,
};
}
/**
* Process <remember> tags from agent output
* <remember>content</remember> -> Working Memory
* <remember priority>content</remember> -> Priority Context
*/
function processRememberTags(output, directory) {
if (!setPriorityContext || !addWorkingMemoryEntry) {
return; // Notepad module not available
}
if (!output || !directory) {
return;
}
// Process priority remember tags first
const priorityRegex = /<remember\s+priority>([\s\S]*?)<\/remember>/gi;
let match;
while ((match = priorityRegex.exec(output)) !== null) {
const content = match[1].trim();
if (content) {
try {
setPriorityContext(directory, content);
} catch {}
}
}
// Process regular remember tags
const regularRegex = /<remember>([\s\S]*?)<\/remember>/gi;
while ((match = regularRegex.exec(output)) !== null) {
const content = match[1].trim();
if (content) {
try {
addWorkingMemoryEntry(directory, content);
} catch {}
}
}
}
// Detect write failure
// Patterns are tightened to tool-level failure phrases to avoid false positives
// when edited file content contains error-handling code (issue #1005)
export function detectWriteFailure(output) {
const cleaned = stripQuotedSpans(stripClaudeTempCwdErrors(output));
const errorPatterns = [
/\berror:/i, // "error:" with word boundary — avoids "setError", "console.error"
/\bfailed to\b/i, // "failed to write" — avoids "failedOidc", UI strings
/\bwrite failed\b/i, // explicit write failure
/\boperation failed\b/i, // explicit operation failure
/permission denied/i, // keep as-is (specific enough)
/read-only/i, // keep as-is
/\bno such file\b/i, // more specific than "not found"
/\bdirectory not found\b/i,
];
return errorPatterns.some(pattern => pattern.test(cleaned));
}
// Detect Claude Code's deterministic write/edit success markers so docs or
// serialized tool output containing diagnostic prose do not override success.
export function isClaudeCodeWriteSuccess(output) {
if (!output) return false;
const cleaned = stripClaudeTempCwdErrors(output);
const successPatterns = [
/(^|\n)The file has been updated successfully\.?(\n|$)/i,
/(^|\n)The file .+ has been updated successfully\.?(\n|$)/i,
/(^|\n)File (?:created|written|updated) successfully(?:\s+at:\s*.+)?\.?(\n|$)/i,
/(^|\n).*file state is current in your context\b.*(\n|$)/i,
];
return successPatterns.some(pattern => pattern.test(cleaned));
}
function extractTextFromKnownToolResponseField(value, depth = 0) {
if (typeof value === 'string') return [value];
if (!value || depth > 4) return [];
if (Array.isArray(value)) {
return value.flatMap(item => extractTextFromKnownToolResponseField(item, depth + 1));
}
if (typeof value !== 'object') return [];
const textFields = ['text', 'message', 'result', 'output', 'stdout'];
const texts = [];
for (const field of textFields) {
if (typeof value[field] === 'string') {
texts.push(value[field]);
}
}
if (
'content' in value &&
value.content &&
(Array.isArray(value.content) || typeof value.content === 'object')
) {
texts.push(...extractTextFromKnownToolResponseField(value.content, depth + 1));
}
return texts;
}
function isStructuredEnvelopePayloadField(fieldName) {
return new Set([
'content',
'oldstring',
'newstring',
'originalfile',
'structuredpatch',
'patch',
'diff',
'lines',
'line',
]).has(fieldName.toLowerCase());
}
function isStructuredEnvelopeStatusTextField(fieldName) {
return new Set(['message', 'output', 'stdout', 'stderr', 'text', 'result'])
.has(fieldName.toLowerCase());
}
function hasExplicitStructuredFailureIndicator(value, depth = 0, fieldName = '') {
if (!value || typeof value === 'string' || depth > 4) return false;
if (fieldName && isStructuredEnvelopePayloadField(fieldName)) return false;
if (Array.isArray(value)) {
return value.some(item => hasExplicitStructuredFailureIndicator(item, depth + 1, fieldName));
}
if (typeof value !== 'object') return false;
for (const [key, fieldValue] of Object.entries(value)) {
const normalizedKey = key.toLowerCase();
if (
typeof fieldValue === 'string' &&
isStructuredEnvelopeStatusTextField(key) &&
detectWriteFailure(fieldValue)
) {
return true;
}
if (
(normalizedKey.includes('error') || normalizedKey.includes('fail')) &&
fieldValue !== false &&
fieldValue !== 0 &&
fieldValue !== null &&
fieldValue !== undefined &&
!(typeof fieldValue === 'string' && fieldValue.trim() === '') &&
!(Array.isArray(fieldValue) && fieldValue.length === 0)
) {
return true;
}
if (hasExplicitStructuredFailureIndicator(fieldValue, depth + 1, key)) {
return true;
}
}
return false;
}
function hasEditEnvelopeSuccess(value, depth = 0) {
if (!value || typeof value === 'string' || depth > 4) return false;
if (Array.isArray(value)) {
return value.some(item => hasEditEnvelopeSuccess(item, depth + 1));
}
if (typeof value !== 'object') return false;
if (hasExplicitStructuredFailureIndicator(value, depth)) return false;
if (typeof value.filePath === 'string' && Array.isArray(value.structuredPatch)) {
return true;
}
return Object.values(value).some(item => hasEditEnvelopeSuccess(item, depth + 1));
}
function hasWriteEnvelopeSuccess(value, depth = 0) {
if (!value || typeof value === 'string' || depth > 4) return false;
if (Array.isArray(value)) {
return value.some(item => hasWriteEnvelopeSuccess(item, depth + 1));
}
if (typeof value !== 'object') return false;
if (hasExplicitStructuredFailureIndicator(value, depth)) return false;
if (
typeof value.filePath === 'string' &&
(value.type === 'create' || value.type === 'update')
) {
return true;
}
return Object.values(value).some(item => hasWriteEnvelopeSuccess(item, depth + 1));
}
function hasStructuredWriteSuccess(rawResponse, toolName = '') {
if (!rawResponse || typeof rawResponse === 'string') return false;
if (toolName === 'Edit' && hasEditEnvelopeSuccess(rawResponse)) return true;
if (toolName === 'Write' && hasWriteEnvelopeSuccess(rawResponse)) return true;
return extractTextFromKnownToolResponseField(rawResponse).some(isClaudeCodeWriteSuccess);
}
function hasStructuredWriteFailure(rawResponse) {
if (!rawResponse || typeof rawResponse === 'string') return false;
return hasExplicitStructuredFailureIndicator(rawResponse);
}
// Get agent completion summary from tracking state.
// Checks session-scoped path first (Wave A migration), falls back to legacy path.
// sessionId is extracted from the hook payload; when absent only the legacy path is tried.
function getAgentCompletionSummary(directory, quietLevel = QUIET_LEVEL, sessionId = '') {
const stateDir = join(resolveOmcRoot(directory), 'state');
const safeSessionId = sessionId && SESSION_ID_ALLOWLIST.test(sessionId) ? sessionId : '';
// Build candidate paths: session-scoped first, then legacy fallback
const candidates = [
safeSessionId ? join(stateDir, 'sessions', safeSessionId, 'subagent-tracking-state.json') : null,
join(stateDir, 'subagent-tracking.json'),
].filter(Boolean);
for (const trackingFile of candidates) {
try {
if (!existsSync(trackingFile)) continue;
const data = JSON.parse(readFileSync(trackingFile, 'utf-8'));
const agents = data.agents || [];
const running = agents.filter(a => a.status === 'running');
const completed = data.total_completed || 0;
const failed = data.total_failed || 0;
if (running.length === 0 && completed === 0 && failed === 0) return '';
const parts = [];
if (quietLevel < 2 && running.length > 0) {
parts.push(`Running: ${running.length} [${running.map(a => a.agent_type.replace('oh-my-claudecode:', '')).join(', ')}]`);
}
if (quietLevel < 2 && completed > 0) parts.push(`Completed: ${completed}`);
if (failed > 0) parts.push(`Failed: ${failed}`);
return parts.join(' | ');
} catch {}
}
return '';
}
// Generate contextual message
function generateMessage(toolName, toolOutput, sessionId, toolCount, directory, options = {}) {
const {
wasTruncated = false,
rawLength = 0,
structuredWriteSuccess = false,
structuredWriteFailure = false,
} = options;
let message = '';
switch (toolName) {
case 'Bash':
if (isNonZeroExitWithOutput(toolOutput)) {
// Non-zero exit with valid output — warning, not error (issue #960)
const exitMatch = toolOutput.match(/Exit code (\d+)/);
const code = exitMatch ? exitMatch[1] : 'non-zero';
message = `Command exited with code ${code} but produced valid output. This may be expected behavior.`;
} else if (detectBashFailure(toolOutput)) {
message = 'Command failed. Please investigate the error and fix before continuing.';
} else if (QUIET_LEVEL < 2 && detectBackgroundOperation(toolOutput)) {
message = 'Background operation detected. Remember to verify results before proceeding.';
}
break;
case 'Task':
case 'TaskCreate':
case 'TaskUpdate': {
const agentSummary = getAgentCompletionSummary(directory, QUIET_LEVEL, sessionId);
if (detectWriteFailure(toolOutput)) {
message = 'Task delegation failed. Verify agent name and parameters.';
} else if (QUIET_LEVEL < 2 && detectBackgroundOperation(toolOutput)) {
message = 'Background task launched. Use TaskOutput to check results when needed.';
} else if (QUIET_LEVEL < 2 && toolCount > 5) {
message = `Multiple tasks delegated (${toolCount} total). Track their completion status.`;
}
if (wasTruncated) {
const truncationNote = `Agent result stream clipped for context safety (${rawLength} chars). Synthesize only key outcomes in main session.`;
message = message ? `${message} | ${truncationNote}` : truncationNote;
}
if (agentSummary) {
message = message ? `${message} | ${agentSummary}` : agentSummary;
}
break;
}
case 'TaskOutput': {
const summary = summarizeAgentResult(toolOutput);
if (QUIET_LEVEL < 2 && summary) {
message = `TaskOutput summary: ${summary}`;
}
if (wasTruncated) {
const truncationNote = `TaskOutput clipped (${rawLength} chars). Continue with concise synthesis and defer full logs to files.`;
message = message ? `${message} | ${truncationNote}` : truncationNote;
}
break;
}
case 'Edit':
if (structuredWriteFailure || (!structuredWriteSuccess && !isClaudeCodeWriteSuccess(toolOutput) && detectWriteFailure(toolOutput))) {
message = 'Edit operation failed. Verify file exists and content matches exactly.';
} else if (QUIET_LEVEL === 0) {
message = 'Code modified. Verify changes work as expected before marking complete.';
}
break;
case 'Write':
if (structuredWriteFailure || (!structuredWriteSuccess && !isClaudeCodeWriteSuccess(toolOutput) && detectWriteFailure(toolOutput))) {
message = 'Write operation failed. Check file permissions and directory existence.';
} else if (QUIET_LEVEL === 0) {
message = 'File written. Test the changes to ensure they work correctly.';
}
break;
case 'TodoWrite':
if (QUIET_LEVEL === 0 && /created|added/i.test(toolOutput)) {
message = 'Todo list updated. Proceed with next task on the list.';
} else if (QUIET_LEVEL === 0 && /completed|done/i.test(toolOutput)) {
message = 'Task marked complete. Continue with remaining todos.';
} else if (QUIET_LEVEL === 0 && /in_progress/i.test(toolOutput)) {
message = 'Task marked in progress. Focus on completing this task.';
}
break;
case 'Read':
if (QUIET_LEVEL === 0 && toolCount > 10) {
message = `Extensive reading (${toolCount} files). Consider using Grep for pattern searches.`;
}
break;
case 'Grep':
if (QUIET_LEVEL === 0 && /^0$|no matches/i.test(toolOutput)) {
message = 'No matches found. Verify pattern syntax or try broader search.';
}
break;
case 'Glob':
if (QUIET_LEVEL === 0 && (!toolOutput.trim() || /no files/i.test(toolOutput))) {
message = 'No files matched pattern. Verify glob syntax and directory.';
}
break;
}
return message;
}
function combineMessages(...messages) {
return messages.filter(Boolean).join(' | ');
}
async function main() {
// Skip guard: check OMC_SKIP_HOOKS env var (see issue #838)
const _skipHooks = (process.env.OMC_SKIP_HOOKS || '').split(',').map(s => s.trim());
if (process.env.DISABLE_OMC === '1' || _skipHooks.includes('post-tool-use')) {
console.log(JSON.stringify({ continue: true }));
return;
}
try {
const input = await readStdin();
const data = JSON.parse(input);
const toolName = data.tool_name || data.toolName || '';
const rawResponse = data.tool_response || data.toolOutput || '';
const structuredWriteSuccess =
(toolName === 'Write' || toolName === 'Edit') && hasStructuredWriteSuccess(rawResponse, toolName);
const structuredWriteFailure =
(toolName === 'Write' || toolName === 'Edit') && hasStructuredWriteFailure(rawResponse);
const toolOutput = typeof rawResponse === 'string' ? rawResponse : JSON.stringify(rawResponse);
const { clipped: clippedToolOutput, wasTruncated } = clipToolOutputForAnalysis(toolName, toolOutput);
const sessionId = data.session_id || data.sessionId || 'unknown';
const directory = data.cwd || data.directory || process.cwd();
// Update session statistics
const toolCount = updateStats(toolName, sessionId);
// Append Bash commands to ~/.bash_history for terminal recall
if ((toolName === 'Bash' || toolName === 'bash') && getBashHistoryConfig()) {
const toolInput = data.tool_input || data.toolInput || {};
const command = typeof toolInput === 'string' ? toolInput : (toolInput.command || '');
appendToBashHistory(command);
}
// Process <remember> tags from Task agent output
if (
toolName === 'Task' ||
toolName === 'task' ||
toolName === 'TaskCreate' ||
toolName === 'TaskUpdate'
) {
processRememberTags(clippedToolOutput, directory);
}
if (toolName === 'Skill' || toolName === 'skill') {
const toolInput = data.tool_input || data.toolInput || {};
const skillName = getInvokedSkillName(toolInput);
const currentState = readSkillActiveState(directory, sessionId);
const completingSkill = (skillName ?? '')
.toLowerCase()
.replace(/^oh-my-claudecode:/, '');
if (!currentState || !currentState.active || currentState.skill_name === completingSkill) {
clearSkillActiveState(directory, sessionId);
}
if (isConsensusPlanningSkillInvocation(skillName, toolInput)) {
deactivateRalplanState(directory, sessionId);
}
}
// Generate contextual message
const message = combineMessages(
generateMessage(toolName, clippedToolOutput, sessionId, toolCount, directory, {
wasTruncated,
rawLength: toolOutput.length,
structuredWriteSuccess,
structuredWriteFailure,
}),
maybeBuildPreemptiveCompactionMessage(toolName, data, directory),
);
// Build response - use hookSpecificOutput.additionalContext for PostToolUse
const response = { continue: true };
if (message) {
response.hookSpecificOutput = {
hookEventName: 'PostToolUse',
additionalContext: message
};
} else {
response.suppressOutput = true;
}
console.log(JSON.stringify(response, null, 2));
} catch (error) {
// On error, always continue
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
}
}
// Only run when executed directly (not when imported for testing)
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}