chore: import upstream snapshot with attribution
CI / Test (ubuntu-latest, Node 18.x, bun) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, npm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, pnpm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, yarn) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 20.x, bun) (push) Failing after 17m13s
CI / Test (ubuntu-latest, Node 20.x, npm) (push) Failing after 18m42s
CI / Test (ubuntu-latest, Node 20.x, pnpm) (push) Failing after 15m0s
CI / Test (ubuntu-latest, Node 20.x, yarn) (push) Failing after 49m44s
CI / Test (ubuntu-latest, Node 22.x, bun) (push) Failing after 51m55s
CI / Test (ubuntu-latest, Node 22.x, pnpm) (push) Failing after 21m57s
CI / Test (ubuntu-latest, Node 22.x, npm) (push) Failing after 37m39s
CI / Test (ubuntu-latest, Node 22.x, yarn) (push) Failing after 34m7s
CI / Validate Components (push) Failing after 37m15s
CI / Python Tests (push) Failing after 10m1s
CI / Security Scan (push) Failing after 10m1s
CI / Lint (push) Failing after 17m12s
CI / Coverage (push) Failing after 20m19s
CI / Test (macos-latest, Node 18.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, yarn) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 11:55:55 +08:00
commit d48cda4081
3322 changed files with 668744 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env node
/**
* Auto-Tmux Dev Hook - Start dev servers in tmux/cmd automatically
*
* macOS/Linux: Runs dev server in a named tmux session (non-blocking).
* Falls back to original command if tmux is not installed.
* Windows: Opens dev server in a new cmd window (non-blocking).
*
* Runs before Bash tool use. If command is a dev server (npm run dev, pnpm dev, yarn dev, bun run dev),
* transforms it to run in a detached session.
*
* Benefits:
* - Dev server runs detached (doesn't block Claude Code)
* - Session persists (can run `tmux capture-pane -t <session> -p` to see logs on Unix)
* - Session name matches project directory (allows multiple projects simultaneously)
*
* Session management (Unix):
* - Checks tmux availability before transforming
* - Kills any existing session with the same name (clean restart)
* - Creates new detached session
* - Reports session name and how to view logs
*
* Session management (Windows):
* - Opens new cmd window with descriptive title
* - Allows multiple dev servers to run simultaneously
*/
const path = require('path');
const { spawnSync } = require('child_process');
const MAX_STDIN = 1024 * 1024; // 1MB limit
let data = '';
function run(rawInput) {
try {
const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
const cmd = input.tool_input?.command || '';
// Detect dev server commands: npm run dev, pnpm dev, yarn dev, bun run dev
// Use word boundary (\b) to avoid matching partial commands
const devServerRegex = /(npm run dev\b|pnpm( run)? dev\b|yarn dev\b|bun run dev\b)/;
if (devServerRegex.test(cmd)) {
// Get session name from current directory basename, sanitize for shell safety
// e.g., /home/user/Portfolio → "Portfolio", /home/user/my-app-v2 → "my-app-v2"
const rawName = path.basename(process.cwd());
// Replace non-alphanumeric characters (except - and _) with underscore to prevent shell injection
const sessionName = rawName.replace(/[^a-zA-Z0-9_-]/g, '_') || 'dev';
if (process.platform === 'win32') {
// Windows: open in a new cmd window (non-blocking)
// Escape double quotes in cmd for cmd /k syntax
const escapedCmd = cmd.replace(/"/g, '""');
return JSON.stringify({
...input,
tool_input: {
...input.tool_input,
command: `start "DevServer-${sessionName}" cmd /k "${escapedCmd}"`,
},
});
} else {
// Unix (macOS/Linux): Check tmux is available before transforming
const tmuxCheck = spawnSync('which', ['tmux'], { encoding: 'utf8' });
if (tmuxCheck.status === 0) {
// Escape single quotes for shell safety: 'text' -> 'text'\''text'
const escapedCmd = cmd.replace(/'/g, "'\\''");
// Build the transformed command:
// 1. Kill existing session (silent if doesn't exist)
// 2. Create new detached session with the dev command
// 3. Echo confirmation message with instructions for viewing logs
const transformedCmd = `SESSION="${sessionName}"; tmux kill-session -t "$SESSION" 2>/dev/null || true; tmux new-session -d -s "$SESSION" '${escapedCmd}' && echo "[Hook] Dev server started in tmux session '${sessionName}'. View logs: tmux capture-pane -t ${sessionName} -p -S -100"`;
return JSON.stringify({
...input,
tool_input: {
...input.tool_input,
command: transformedCmd,
},
});
}
// else: tmux not found, pass through original command unchanged
}
}
return JSON.stringify(input);
} catch {
// Invalid input — pass through original data unchanged
return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
}
}
if (require.main === module) {
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
process.stdout.write(run(data));
process.exit(0);
});
}
module.exports = { run };
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env node
'use strict';
const { isHookEnabled } = require('../lib/hook-flags');
const {
buildPreToolUseAdditionalContext,
combineAdditionalContext,
} = require('./pretooluse-visible-output');
const { run: runBlockNoVerify } = require('./block-no-verify');
const { run: runAutoTmuxDev } = require('./auto-tmux-dev');
const { run: runTmuxReminder } = require('./pre-bash-tmux-reminder');
const { run: runGitPushReminder } = require('./pre-bash-git-push-reminder');
const { run: runCommitQuality } = require('./pre-bash-commit-quality');
const { run: runGateGuard } = require('./gateguard-fact-force');
const { run: runCommandLog } = require('./post-bash-command-log');
const { run: runPrCreated } = require('./post-bash-pr-created');
const { run: runBuildComplete } = require('./post-bash-build-complete');
const MAX_STDIN = 1024 * 1024;
const PRE_BASH_HOOKS = [
{
id: 'pre:bash:block-no-verify',
profiles: 'minimal,standard,strict',
run: rawInput => runBlockNoVerify(rawInput),
},
{
id: 'pre:bash:auto-tmux-dev',
run: rawInput => runAutoTmuxDev(rawInput),
},
{
id: 'pre:bash:tmux-reminder',
profiles: 'strict',
run: rawInput => runTmuxReminder(rawInput),
},
{
id: 'pre:bash:git-push-reminder',
profiles: 'strict',
run: rawInput => runGitPushReminder(rawInput),
},
{
id: 'pre:bash:commit-quality',
profiles: 'strict',
run: rawInput => runCommitQuality(rawInput),
},
{
id: 'pre:bash:gateguard-fact-force',
profiles: 'standard,strict',
run: rawInput => runGateGuard(rawInput),
},
];
const POST_BASH_HOOKS = [
{
id: 'post:bash:command-log-audit',
run: rawInput => runCommandLog(rawInput, 'audit'),
},
{
id: 'post:bash:command-log-cost',
run: rawInput => runCommandLog(rawInput, 'cost'),
},
{
id: 'post:bash:pr-created',
profiles: 'standard,strict',
run: rawInput => runPrCreated(rawInput),
},
{
id: 'post:bash:build-complete',
profiles: 'standard,strict',
run: rawInput => runBuildComplete(rawInput),
},
];
function readStdinRaw() {
return new Promise(resolve => {
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => resolve(raw));
process.stdin.on('error', () => resolve(raw));
});
}
function normalizeHookResult(previousRaw, output) {
if (typeof output === 'string' || Buffer.isBuffer(output)) {
return {
raw: String(output),
stderr: '',
exitCode: 0,
};
}
if (output && typeof output === 'object') {
const nextRaw = Object.prototype.hasOwnProperty.call(output, 'additionalContext')
? previousRaw
: Object.prototype.hasOwnProperty.call(output, 'stdout')
? String(output.stdout ?? '')
: !Number.isInteger(output.exitCode) || output.exitCode === 0
? previousRaw
: '';
return {
raw: nextRaw,
stderr: typeof output.stderr === 'string' ? output.stderr : '',
additionalContext: output.additionalContext,
exitCode: Number.isInteger(output.exitCode) ? output.exitCode : 0,
};
}
return {
raw: previousRaw,
stderr: '',
exitCode: 0,
};
}
function runHooks(rawInput, hooks) {
let currentRaw = rawInput;
// Track whether a sub-hook deliberately produced stdout (a string or
// {stdout}) versus currentRaw still being the untouched input event.
// Echoing the unmodified input event back to stdout fails Claude Code's
// hook-output JSON schema validation ("(root): Invalid input"), so in the
// pass-through case we must emit nothing instead.
let rawModified = false;
let stderr = '';
let additionalContext = '';
for (const hook of hooks) {
if (!isHookEnabled(hook.id, { profiles: hook.profiles })) {
continue;
}
try {
const result = normalizeHookResult(currentRaw, hook.run(currentRaw));
if (result.raw !== currentRaw) {
rawModified = true;
}
currentRaw = result.raw;
if (result.stderr) {
stderr += result.stderr.endsWith('\n') ? result.stderr : `${result.stderr}\n`;
}
if (result.additionalContext) {
additionalContext = combineAdditionalContext(additionalContext, result.additionalContext);
}
if (result.exitCode !== 0) {
return {
output: rawModified ? currentRaw : '',
stderr,
additionalContext,
exitCode: result.exitCode,
};
}
} catch (error) {
stderr += `[Hook] ${hook.id} failed: ${error.message}\n`;
}
}
return {
output: additionalContext
? buildPreToolUseAdditionalContext(additionalContext)
: rawModified
? currentRaw
: '',
stderr,
additionalContext,
exitCode: 0,
};
}
function runPreBash(rawInput) {
return runHooks(rawInput, PRE_BASH_HOOKS);
}
function runPostBash(rawInput) {
return runHooks(rawInput, POST_BASH_HOOKS);
}
async function main() {
const mode = process.argv[2];
const raw = await readStdinRaw();
const result = mode === 'post'
? runPostBash(raw)
: runPreBash(raw);
if (result.stderr) {
process.stderr.write(result.stderr);
}
process.stdout.write(result.output);
process.exit(result.exitCode);
}
if (require.main === module) {
main().catch(error => {
process.stderr.write(`[Hook] bash-hook-dispatcher failed: ${error.message}\n`);
process.exit(0);
});
}
module.exports = {
PRE_BASH_HOOKS,
POST_BASH_HOOKS,
runPreBash,
runPostBash,
};
+546
View File
@@ -0,0 +1,546 @@
#!/usr/bin/env node
/**
* PreToolUse Hook: Block --no-verify flag
*
* Blocks git hook-bypass flags (--no-verify, -c core.hooksPath=) to protect
* pre-commit, commit-msg, and pre-push hooks from being skipped by AI agents.
*
* Replaces the previous npx-based invocation that failed in pnpm-only projects
* (EBADDEVENGINES) and could not be disabled via ECC_DISABLED_HOOKS.
*
* Exit codes:
* 0 = allow (not a git command or no bypass flags)
* 2 = block (bypass flag detected)
*/
'use strict';
const MAX_STDIN = 1024 * 1024;
let raw = '';
/**
* Git commands that support the --no-verify flag.
*/
const GIT_COMMANDS_WITH_NO_VERIFY = [
'commit',
'push',
'merge',
'cherry-pick',
'rebase',
'am',
];
/**
* Characters that can appear immediately before 'git' in a command string.
*/
const VALID_BEFORE_GIT = ' \t\n\r;&|$`(<{!"\']/.~\\';
// Git config section and variable names are case-insensitive
// (subsection names are case-sensitive but core.hooksPath has none),
// so we normalize the candidate token to lowercase before matching.
// See https://git-scm.com/docs/git-config — "The variable names are
// case-insensitive."
const GIT_CONFIG_KEY_PREFIX = 'core.hookspath=';
const COMMIT_OPTIONS_WITH_VALUE = new Set([
'-m',
'--message',
'-F',
'--file',
'-C',
'--reuse-message',
'-c',
'--reedit-message',
'--author',
'--date',
'--template',
'--fixup',
'--squash',
'--pathspec-from-file',
]);
const COMMIT_OPTIONS_WITH_INLINE_VALUE = [
'--message=',
'--file=',
'--reuse-message=',
'--reedit-message=',
'--author=',
'--date=',
'--template=',
'--fixup=',
'--squash=',
'--pathspec-from-file=',
];
// Short options that take a value. When seen as part of a combined
// short-option token (e.g. -tn), git's parser treats the rest of the
// token as the option's value (template path 'n' here), so the scanner
// must stop at this character — anything after it is the inline value,
// not another flag.
const COMMIT_SHORT_OPTIONS_WITH_VALUE = new Set(['m', 'F', 'C', 'c', 't']);
function tokenizeShellWords(input, start = 0, end = input.length) {
const tokens = [];
let value = '';
let tokenStart = null;
let quote = null;
let escaped = false;
function beginToken(index) {
if (tokenStart === null) {
tokenStart = index;
}
}
function pushToken(index) {
if (tokenStart === null) {
return;
}
tokens.push({
value,
start: tokenStart,
end: index,
});
value = '';
tokenStart = null;
}
for (let i = start; i < end; i++) {
const char = input.charAt(i);
if (escaped) {
beginToken(i - 1);
value += char;
escaped = false;
continue;
}
if (quote) {
if (char === quote) {
quote = null;
continue;
}
if (quote === '"' && char === '\\') {
beginToken(i);
escaped = true;
continue;
}
beginToken(i);
value += char;
continue;
}
if (char === '"' || char === "'") {
beginToken(i);
quote = char;
continue;
}
if (char === '\\') {
beginToken(i);
escaped = true;
continue;
}
if (/\s/.test(char)) {
pushToken(i);
continue;
}
beginToken(i);
value += char;
}
if (escaped) {
value += '\\';
}
pushToken(end);
return tokens;
}
function findCommandSegmentEnd(input, start) {
let quote = null;
let escaped = false;
for (let i = start; i < input.length; i++) {
const char = input.charAt(i);
if (escaped) {
escaped = false;
continue;
}
if (quote) {
if (quote === '"' && char === '\\') {
escaped = true;
continue;
}
if (char === quote) {
quote = null;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '\\') {
escaped = true;
continue;
}
if (char === ';' || char === '|' || char === '&' || char === '\n') {
return i;
}
}
return input.length;
}
function commitOptionConsumesNextValue(value) {
if (isCommitNoVerifyShortFlag(value)) {
return false;
}
if (COMMIT_OPTIONS_WITH_VALUE.has(value)) {
return true;
}
const shortValueOption = getCommitShortValueOption(value);
return Boolean(shortValueOption && shortValueOption.consumesNextValue);
}
function commitOptionContainsInlineValue(value) {
if (isCommitNoVerifyShortFlag(value)) {
return false;
}
if (COMMIT_OPTIONS_WITH_INLINE_VALUE.some(prefix => value.startsWith(prefix))) {
return true;
}
const shortValueOption = getCommitShortValueOption(value);
return Boolean(shortValueOption && shortValueOption.containsInlineValue);
}
function getCommitShortValueOption(value) {
if (!value.startsWith('-') || value.startsWith('--') || value === '-') {
return null;
}
const options = value.slice(1);
for (let i = 0; i < options.length; i++) {
if (COMMIT_SHORT_OPTIONS_WITH_VALUE.has(options.charAt(i))) {
return {
consumesNextValue: i === options.length - 1,
containsInlineValue: i < options.length - 1,
};
}
}
return null;
}
function isCommitNoVerifyShortFlag(value) {
return value === '-n' || /^-n[a-zA-Z]/.test(value);
}
/**
* Check if a position in the input is inside a shell comment.
*/
function isInComment(input, idx) {
const lineStart = input.lastIndexOf('\n', idx - 1) + 1;
const before = input.slice(lineStart, idx);
for (let i = 0; i < before.length; i++) {
if (before.charAt(i) === '#') {
const prev = i > 0 ? before.charAt(i - 1) : '';
if (prev !== '$' && prev !== '\\') return true;
}
}
return false;
}
/**
* Find the next 'git' token in the input starting from a position.
*/
function findGit(input, start) {
let pos = start;
while (pos < input.length) {
const idx = input.indexOf('git', pos);
if (idx === -1) return null;
const isExe = input.slice(idx + 3, idx + 7).toLowerCase() === '.exe';
const len = isExe ? 7 : 3;
const after = input[idx + len] || ' ';
if (!/[\s"']/.test(after)) {
pos = idx + 1;
continue;
}
const before = idx > 0 ? input[idx - 1] : ' ';
if (VALID_BEFORE_GIT.includes(before)) return { idx, len };
pos = idx + 1;
}
return null;
}
/**
* Detect which git subcommand (commit, push, etc.) is being invoked.
* Returns { command, offset } where offset is the position right after the
* subcommand keyword, so callers can scope flag checks to only that portion.
*/
function detectGitCommand(input, start = 0) {
while (start < input.length) {
const git = findGit(input, start);
if (!git) return null;
if (isInComment(input, git.idx)) {
start = git.idx + git.len;
continue;
}
// Find the first matching subcommand token after "git".
// We pick the one closest to "git" so that argument values like
// "git push origin commit" don't misclassify "commit" as the subcommand.
let bestCmd = null;
let bestIdx = Infinity;
for (const cmd of GIT_COMMANDS_WITH_NO_VERIFY) {
let searchPos = git.idx + git.len;
while (searchPos < input.length) {
const cmdIdx = input.indexOf(cmd, searchPos);
if (cmdIdx === -1) break;
const before = cmdIdx > 0 ? input[cmdIdx - 1] : ' ';
const after = input[cmdIdx + cmd.length] || ' ';
if (!/\s/.test(before)) { searchPos = cmdIdx + 1; continue; }
if (!/[\s;&#|>)\]}"']/.test(after) && after !== '') { searchPos = cmdIdx + 1; continue; }
if (/[;|]/.test(input.slice(git.idx + git.len, cmdIdx))) break;
if (isInComment(input, cmdIdx)) { searchPos = cmdIdx + 1; continue; }
// Verify this token is the first non-flag word after "git" — i.e. the
// actual subcommand, not an argument value to a different subcommand.
const gap = input.slice(git.idx + git.len, cmdIdx);
const tokens = gap.trim().split(/\s+/).filter(Boolean);
// Every token before the candidate must be a flag or a flag argument.
// Git global flags like -c take a value argument (e.g. -c key=value).
let onlyFlagsAndArgs = true;
let expectFlagArg = false;
for (const t of tokens) {
if (expectFlagArg) { expectFlagArg = false; continue; }
if (t.startsWith('-')) {
// -c is a git global flag that takes the next token as its argument
if (t === '-c' || t === '-C' || t === '--work-tree' || t === '--git-dir' ||
t === '--namespace' || t === '--super-prefix') {
expectFlagArg = true;
}
continue;
}
onlyFlagsAndArgs = false;
break;
}
if (!onlyFlagsAndArgs) { searchPos = cmdIdx + 1; continue; }
if (cmdIdx < bestIdx) {
bestIdx = cmdIdx;
bestCmd = cmd;
}
break;
}
}
if (bestCmd) {
return {
command: bestCmd,
offset: bestIdx + bestCmd.length,
gitStart: git.idx,
gitEnd: git.idx + git.len,
commandStart: bestIdx,
};
}
start = git.idx + git.len;
}
return null;
}
/**
* Check if the input contains a --no-verify flag for a specific git command.
* Only inspects the portion of the input starting at `offset` (the position
* right after the detected subcommand keyword) so that flags belonging to
* earlier commands in a chain are not falsely matched.
*/
function hasNoVerifyFlag(input, command, offset) {
const segmentEnd = findCommandSegmentEnd(input, offset);
const tokens = tokenizeShellWords(input, offset, segmentEnd);
let skipNext = false;
for (const token of tokens) {
const value = token.value;
if (skipNext) {
skipNext = false;
continue;
}
if (value === '--') {
break;
}
if (command === 'commit') {
if (commitOptionConsumesNextValue(value)) {
skipNext = true;
continue;
}
if (commitOptionContainsInlineValue(value)) {
continue;
}
}
if (value === '--no-verify') return true;
// For commit, -n is shorthand for --no-verify.
if (command === 'commit' && isCommitNoVerifyShortFlag(value)) {
return true;
}
}
return false;
}
/**
* Check if the input contains a -c core.hooksPath= override.
*/
function hasHooksPathOverride(input, detected) {
const tokens = tokenizeShellWords(input, detected.gitEnd, detected.commandStart);
for (let i = 0; i < tokens.length; i++) {
const value = tokens[i].value;
// Git config section + variable names are case-insensitive, so a
// bypass attempt like `core.HOOKSPATH=...` or `core.hookspath=...`
// must compare against the lowercased token.
const lowered = value.toLowerCase();
if (value === '-c') {
const next = tokens[i + 1] && tokens[i + 1].value;
if (typeof next === 'string' && next.toLowerCase().startsWith(GIT_CONFIG_KEY_PREFIX)) {
return true;
}
i++;
continue;
}
if (lowered.startsWith(`-c${GIT_CONFIG_KEY_PREFIX}`)) {
return true;
}
}
return false;
}
/**
* Check a command string for git hook bypass attempts.
*/
function checkCommand(input) {
let start = 0;
while (start < input.length) {
const detected = detectGitCommand(input, start);
if (!detected) return { blocked: false };
const { command: gitCommand, offset } = detected;
if (hasHooksPathOverride(input, detected)) {
return {
blocked: true,
reason: `BLOCKED: Overriding core.hooksPath is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`,
};
}
if (hasNoVerifyFlag(input, gitCommand, offset)) {
return {
blocked: true,
reason: `BLOCKED: --no-verify flag is not allowed with git ${gitCommand}. Git hooks must not be bypassed.`,
};
}
start = findCommandSegmentEnd(input, offset) + 1;
}
return { blocked: false };
}
/**
* Extract the command string from hook input (JSON or plain text).
*/
function extractCommand(rawInput) {
const trimmed = rawInput.trim();
if (!trimmed.startsWith('{')) return trimmed;
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== 'object' || parsed === null) return trimmed;
// Claude Code format: { tool_input: { command: "..." } }
const cmd = parsed.tool_input?.command;
if (typeof cmd === 'string') return cmd;
// Generic JSON formats
for (const key of ['command', 'cmd', 'input', 'shell', 'script']) {
if (typeof parsed[key] === 'string') return parsed[key];
}
return trimmed;
} catch {
return trimmed;
}
}
/**
* Exportable run() for in-process execution via run-with-flags.js.
*/
function run(rawInput) {
const command = extractCommand(rawInput);
const result = checkCommand(command);
if (result.blocked) {
return {
exitCode: 2,
stderr: result.reason,
};
}
return { exitCode: 0 };
}
module.exports = { run };
// Stdin fallback for spawnSync execution — only when invoked directly, not via require()
if (require.main === module) {
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const command = extractCommand(raw);
const result = checkCommand(command);
if (result.blocked) {
process.stderr.write(result.reason + '\n');
process.exit(2);
}
process.stdout.write(raw);
});
}
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env node
/**
* Stop Hook: Check for console.log statements in modified files
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs after each response and checks if any modified JavaScript/TypeScript
* files contain console.log statements. Provides warnings to help developers
* remember to remove debug statements before committing.
*
* Exclusions: test files, config files, and scripts/ directory (where
* console.log is often intentional).
*/
const fs = require('fs');
const { isGitRepo, getGitModifiedFiles, readFile, log } = require('../lib/utils');
// Files where console.log is expected and should not trigger warnings
const EXCLUDED_PATTERNS = [
/\.test\.[jt]sx?$/,
/\.spec\.[jt]sx?$/,
/\.config\.[jt]s$/,
/scripts\//,
/__tests__\//,
/__mocks__\//,
];
const MAX_STDIN = 1024 * 1024; // 1MB limit
let data = '';
let truncated = false;
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += chunk.substring(0, remaining);
if (chunk.length > remaining) truncated = true;
} else {
truncated = true;
}
});
/**
* Echo stdin back (ECC pass-through convention), then exit once the pipe has
* flushed. Truncated stdin is never echoed: a JSON document cut mid-stream is
* reported by the harness as a Stop hook JSON validation failure (#2090).
*/
function passThroughAndExit() {
if (truncated) {
log('[Hook] check-console-log: stdin exceeded 1MB; suppressing pass-through (fail-open)');
process.exit(0);
}
if (!data) {
process.exit(0);
}
process.stdout.write(data, () => process.exit(0));
}
process.stdin.on('end', () => {
try {
if (!isGitRepo()) {
passThroughAndExit();
return;
}
const files = getGitModifiedFiles(['\\.tsx?$', '\\.jsx?$'])
.filter(f => fs.existsSync(f))
.filter(f => !EXCLUDED_PATTERNS.some(pattern => pattern.test(f)));
let hasConsole = false;
for (const file of files) {
const content = readFile(file);
if (content && content.includes('console.log')) {
log(`[Hook] WARNING: console.log found in ${file}`);
hasConsole = true;
}
}
if (hasConsole) {
log('[Hook] Remove console.log statements before committing');
}
} catch (err) {
log(`[Hook] check-console-log error: ${err.message}`);
}
// Always output the original data (unless truncated)
passThroughAndExit();
});
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env node
'use strict';
const { isHookEnabled } = require('../lib/hook-flags');
const [, , hookId, profilesCsv] = process.argv;
if (!hookId) {
process.stdout.write('yes');
process.exit(0);
}
process.stdout.write(isHookEnabled(hookId, { profiles: profilesCsv }) ? 'yes' : 'no');
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env node
/**
* Config Protection Hook
*
* Blocks modifications to linter/formatter config files.
* Agents frequently modify these to make checks pass instead of fixing
* the actual code. This hook steers the agent back to fixing the source.
*
* Exit codes:
* 0 = allow (not a config file, or first-time creation of one)
* 2 = block (existing config file modification attempted)
*/
'use strict';
const fs = require('fs');
const path = require('path');
const MAX_STDIN = 1024 * 1024;
let raw = '';
const PROTECTED_FILES = new Set([
// ESLint (legacy + v9 flat config, JS/TS/MJS/CJS)
'.eslintrc',
'.eslintrc.js',
'.eslintrc.cjs',
'.eslintrc.json',
'.eslintrc.yml',
'.eslintrc.yaml',
'eslint.config.js',
'eslint.config.mjs',
'eslint.config.cjs',
'eslint.config.ts',
'eslint.config.mts',
'eslint.config.cts',
// Prettier (all config variants including ESM)
'.prettierrc',
'.prettierrc.js',
'.prettierrc.cjs',
'.prettierrc.json',
'.prettierrc.yml',
'.prettierrc.yaml',
'prettier.config.js',
'prettier.config.cjs',
'prettier.config.mjs',
// Biome
'biome.json',
'biome.jsonc',
// Ruff (Python)
'.ruff.toml',
'ruff.toml',
// Note: pyproject.toml is intentionally NOT included here because it
// contains project metadata alongside linter config. Blocking all edits
// to pyproject.toml would prevent legitimate dependency changes.
// Shell / Style / Markdown
'.shellcheckrc',
'.stylelintrc',
'.stylelintrc.json',
'.stylelintrc.yml',
'.markdownlint.json',
'.markdownlint.yaml',
'.markdownlintrc'
]);
function parseInput(inputOrRaw) {
if (typeof inputOrRaw === 'string') {
try {
return inputOrRaw.trim() ? JSON.parse(inputOrRaw) : {};
} catch {
return {};
}
}
return inputOrRaw && typeof inputOrRaw === 'object' ? inputOrRaw : {};
}
/**
* Exportable run() for in-process execution via run-with-flags.js.
* Avoids the ~50-100ms spawnSync overhead when available.
*/
function run(inputOrRaw, options = {}) {
if (options.truncated) {
return {
exitCode: 2,
stderr:
`BLOCKED: Hook input exceeded ${options.maxStdin || MAX_STDIN} bytes. ` +
'Refusing to bypass config-protection on a truncated payload. ' +
'Retry with a smaller edit or disable the config-protection hook temporarily.'
};
}
const input = parseInput(inputOrRaw);
const filePath = input?.tool_input?.file_path || input?.tool_input?.file || '';
if (!filePath) return { exitCode: 0 };
const basename = path.basename(filePath);
if (PROTECTED_FILES.has(basename)) {
// Allow first-time creation — there's no existing config to weaken.
// The hook's purpose is blocking modifications; writing a brand-new
// config file in a project that has none is a legitimate bootstrap
// path (e.g. scaffolding ESLint into a fresh repo).
//
// Fail closed on any stat error other than ENOENT. Use lstatSync so a
// symlink at the protected path is treated as present even if its target
// is missing — a dangling symlink at e.g. .eslintrc.js still represents
// an existing config entry that an agent should not silently replace.
// fs.existsSync would swallow EACCES/EPERM as false; lstatSync exposes
// the error code so we can treat only genuine "path not found" (ENOENT)
// as absent.
let exists = true;
try {
fs.lstatSync(filePath);
// lstat succeeded — something (file, dir, or symlink) exists here.
} catch (err) {
if (err && err.code === 'ENOENT') {
exists = false;
}
// Any other error (EACCES, EPERM, ELOOP, etc.) leaves exists=true
// so the guard is never silently weakened.
}
if (!exists) {
return { exitCode: 0 };
}
return {
exitCode: 2,
stderr:
`BLOCKED: Modifying ${basename} is not allowed. ` +
'Fix the source code to satisfy linter/formatter rules instead of ' +
'weakening the config. If this is a legitimate config change, ' +
'disable the config-protection hook temporarily.'
};
}
return { exitCode: 0 };
}
module.exports = { run };
// Stdin fallback for spawnSync execution
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
if (chunk.length > remaining) truncated = true;
} else {
truncated = true;
}
});
process.stdin.on('end', () => {
const result = run(raw, {
truncated,
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN
});
if (result.stderr) {
process.stderr.write(result.stderr + '\n');
}
if (result.exitCode === 2) {
process.exit(2);
}
process.stdout.write(raw);
});
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env node
/**
* Cost Tracker Hook (v2)
*
* Reads transcript_path from Stop hook stdin, sums usage across all
* assistant turns in the session JSONL, and appends one row to
* ~/.claude/metrics/costs.jsonl.
*
* Stop hook stdin payload: { session_id, transcript_path, cwd, hook_event_name, ... }
* The Stop payload does NOT include `usage` or `model` directly. The previous
* version of this hook expected those fields and silently produced zero-filled
* rows (verified: 2,340 rows captured with 0.0% non-zero token rate over 52
* days). The fix is to read the transcript file Claude Code already passes us.
*
* JSONL assistant entry shape (per Claude Code):
* { type: "assistant", message: { model, usage: { input_tokens, output_tokens,
* cache_creation_input_tokens, cache_read_input_tokens } } }
*
* Cumulative behavior: Stop fires per assistant response, not per session.
* Each row therefore represents the cumulative session total up to that point.
* To get per-session cost, take the last row per session_id. To get per-day
* spend, aggregate.
*
* Harness-cost contract (optional, opt-in by the statusline):
* If the user's statusline (which receives `cost.total_cost_usd` directly
* from Claude Code) writes `{ts, cost_usd}` to
* `<os.tmpdir()>/harness-cost-<session_id>.json` on each render, this hook
* prefers that authoritative value over the transcript-sum estimate when
* the cache is fresh (≤ 300s). The transcript-sum is kept as a safe
* fallback because:
* - the hard-coded rate table cannot represent Opus 4.7's >200K-token
* 2x tier or the 1h-cache 2x tier (under-counts on long sessions);
* - summing the full transcript double-counts work done across
* `--resume` boundaries while `cost.total_cost_usd` is per-process.
* Absent a writer, behavior is unchanged.
*/
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { ensureDir, appendFile, getClaudeDir } = require('../lib/utils');
const { sanitizeSessionId } = require('../lib/session-bridge');
const HARNESS_COST_MAX_AGE_SECONDS = 300;
/**
* Read authoritative harness cost from the per-session cache file.
* @param {string} sessionId
* @param {number} maxAgeSeconds
* @returns {number|null} cost in USD, or null on miss / stale / parse error
*/
function readHarnessCost(sessionId, maxAgeSeconds) {
if (!sessionId) return null;
try {
const fp = path.join(os.tmpdir(), `harness-cost-${sessionId}.json`);
if (!fs.existsSync(fp)) return null;
const obj = JSON.parse(fs.readFileSync(fp, 'utf8'));
const ts = Number(obj && obj.ts);
const cost = Number(obj && obj.cost_usd);
if (!Number.isFinite(ts) || !Number.isFinite(cost) || cost < 0) return null;
const age = Math.floor(Date.now() / 1000) - ts;
if (age < 0 || age > maxAgeSeconds) return null;
return cost;
} catch {
return null;
}
}
// Approximate per-1M-token billing rates (USD).
// Cache creation: 1.25x input rate. Cache read: 0.1x input rate.
const RATE_TABLE = {
haiku: { in: 0.80, out: 4.0, cacheWrite: 1.00, cacheRead: 0.08 },
sonnet: { in: 3.00, out: 15.0, cacheWrite: 3.75, cacheRead: 0.30 },
opus: { in: 15.00, out: 75.0, cacheWrite: 18.75, cacheRead: 1.50 }
};
function getRates(model) {
const m = String(model || '').toLowerCase();
if (m.includes('haiku')) return RATE_TABLE.haiku;
if (m.includes('opus')) return RATE_TABLE.opus;
return RATE_TABLE.sonnet;
}
function toNumber(v) {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
/**
* Scan the session JSONL and sum token usage across all assistant turns.
* Returns { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model }
* or null on read failure.
*/
function sumUsageFromTranscript(transcriptPath) {
let content;
try {
content = fs.readFileSync(transcriptPath, 'utf8');
} catch {
return null;
}
let inputTokens = 0;
let outputTokens = 0;
let cacheWriteTokens = 0;
let cacheReadTokens = 0;
let model = 'unknown';
for (const line of content.split('\n')) {
if (!line.trim()) continue;
let entry;
try { entry = JSON.parse(line); } catch { continue; }
if (entry.type !== 'assistant') continue;
const msg = entry.message;
if (!msg || !msg.usage) continue;
const u = msg.usage;
inputTokens += toNumber(u.input_tokens);
outputTokens += toNumber(u.output_tokens);
cacheWriteTokens += toNumber(u.cache_creation_input_tokens);
cacheReadTokens += toNumber(u.cache_read_input_tokens);
if (msg.model && msg.model !== 'unknown') model = msg.model;
}
return { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model };
}
// 1MB, matching the other Stop hooks. The Stop payload carries
// last_assistant_message, which routinely exceeded the old 64KB cap and
// made this hook echo a JSON document cut mid-stream (#2090).
const MAX_STDIN = 1024 * 1024;
let raw = '';
let truncated = false;
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
if (chunk.length > remaining) truncated = true;
} else {
truncated = true;
}
});
process.stdin.on('end', () => {
try {
const input = raw.trim() ? JSON.parse(raw) : {};
const transcriptPath = (typeof input.transcript_path === 'string' && input.transcript_path)
? input.transcript_path
: process.env.CLAUDE_TRANSCRIPT_PATH || null;
const sessionId =
sanitizeSessionId(input.session_id) ||
sanitizeSessionId(process.env.ECC_SESSION_ID) ||
sanitizeSessionId(process.env.CLAUDE_SESSION_ID) ||
'default';
let usageTotals = null;
if (transcriptPath && fs.existsSync(transcriptPath)) {
usageTotals = sumUsageFromTranscript(transcriptPath);
}
const {
inputTokens = 0,
outputTokens = 0,
cacheWriteTokens = 0,
cacheReadTokens = 0,
model = 'unknown'
} = usageTotals || {};
const rates = getRates(model);
const transcriptCostUsd = Math.round((
(inputTokens / 1e6) * rates.in +
(outputTokens / 1e6) * rates.out +
(cacheWriteTokens / 1e6) * rates.cacheWrite +
(cacheReadTokens / 1e6) * rates.cacheRead
) * 1e6) / 1e6;
// Prefer the harness's authoritative `cost.total_cost_usd` when the
// statusline has written it to the per-session cache (see contract in
// the file header). The harness number reflects API-billed truth
// (correct rates, 1h-cache 2x, >200K tier 2x) and is per-process so it
// does not drift across `--resume`. Cache miss → transcript-sum.
const harnessCost = readHarnessCost(sessionId, HARNESS_COST_MAX_AGE_SECONDS);
const estimatedCostUsd = harnessCost !== null
? Math.round(harnessCost * 1e6) / 1e6
: transcriptCostUsd;
const metricsDir = path.join(getClaudeDir(), 'metrics');
ensureDir(metricsDir);
const row = {
timestamp: new Date().toISOString(),
session_id: sessionId,
transcript_path: transcriptPath || '',
model,
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_write_tokens: cacheWriteTokens,
cache_read_tokens: cacheReadTokens,
estimated_cost_usd: estimatedCostUsd
};
appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`);
} catch {
// Non-blocking — never fail the Stop hook.
}
// Pass stdin through (ECC hook convention) — but never echo truncated
// stdin: invalid JSON on stdout is reported as a Stop hook failure (#2090).
if (truncated) {
process.stderr.write('[Hook] cost-tracker: stdin exceeded 1MB; suppressing pass-through (fail-open)\n');
return;
}
process.stdout.write(raw);
});
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env node
/**
* Cursor sessionStart hook — inject ECC_AGENT_DATA_HOME for the composer session.
*
* Cursor passes session-scoped env from sessionStart output to all later hooks.
* @see https://cursor.com/docs/hooks
*/
const {
getCursorSessionEnvPayload,
resolveAgentDataHome,
AGENT_DATA_HOME_ENV,
} = require('../lib/agent-data-home');
const { readStdinJson, log } = require('../lib/utils');
function main() {
readStdinJson()
.then(() => {
const envPayload = getCursorSessionEnvPayload({ preferCursorDefault: true });
const agentDataHome = envPayload[AGENT_DATA_HOME_ENV];
const payload = {
env: envPayload,
additional_context: [
'ECC memory persistence uses a dedicated agent data root for this Cursor session.',
`${AGENT_DATA_HOME_ENV}=${agentDataHome}`,
'Session summaries, learned skills, aliases, and metrics live under that directory.',
'Override via shell env, project .cursor/ecc-agent-data.json, or ECC docs (issue #2065).',
].join('\n'),
};
process.stdout.write(`${JSON.stringify(payload)}\n`);
log(`[cursor-session-env] Set ${AGENT_DATA_HOME_ENV}=${agentDataHome}`);
process.exit(0);
})
.catch(error => {
const fallbackHome = resolveAgentDataHome({ preferCursorDefault: true });
const payload = {
env: { [AGENT_DATA_HOME_ENV]: fallbackHome },
};
process.stdout.write(`${JSON.stringify(payload)}\n`);
log(`[cursor-session-env] Fallback ${AGENT_DATA_HOME_ENV}=${fallbackHome} (${error.message})`);
process.exit(0);
});
}
if (require.main === module) {
main();
}
module.exports = { main };
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env node
/**
* PostToolUse hook: lightweight frontend design-quality reminder.
*
* This stays self-contained inside ECC. It does not call remote models or
* install packages. The goal is to catch obviously generic UI drift and keep
* frontend edits aligned with ECC's stronger design standards.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const FRONTEND_EXTENSIONS = /\.(astro|css|html|jsx|scss|svelte|tsx|vue)$/i;
const MAX_STDIN = 1024 * 1024;
const GENERIC_SIGNALS = [
{ pattern: /\bget started\b/i, label: '"Get Started" CTA copy' },
{ pattern: /\blearn more\b/i, label: '"Learn more" CTA copy' },
{ pattern: /\bgrid-cols-(3|4)\b/, label: 'uniform multi-card grid' },
{ pattern: /\bbg-gradient-to-[trbl]/, label: 'stock gradient utility usage' },
{ pattern: /\btext-center\b/, label: 'centered default layout cues' },
{ pattern: /\bfont-(sans|inter)\b/i, label: 'default font utility' },
];
const CHECKLIST = [
'visual hierarchy with real contrast',
'intentional spacing rhythm',
'depth, layering, or overlap',
'purposeful hover and focus states',
'color and typography that feel specific',
];
function getFilePaths(input) {
const toolInput = input?.tool_input || {};
if (toolInput.file_path) {
return [String(toolInput.file_path)];
}
if (Array.isArray(toolInput.edits)) {
return toolInput.edits
.map(edit => String(edit?.file_path || ''))
.filter(Boolean);
}
return [];
}
function readContent(filePath) {
try {
return fs.readFileSync(path.resolve(filePath), 'utf8');
} catch {
return '';
}
}
function detectSignals(content) {
return GENERIC_SIGNALS.filter(signal => signal.pattern.test(content)).map(signal => signal.label);
}
function buildWarning(frontendPaths, findings) {
const pathLines = frontendPaths.map(fp => ` - ${fp}`).join('\n');
const signalLines = findings.length > 0
? findings.map(item => ` - ${item}`).join('\n')
: ' - no obvious canned-template strings detected';
return [
'[Hook] DESIGN CHECK: frontend file(s) modified:',
pathLines,
'[Hook] Review for generic/template drift. Frontend should have:',
CHECKLIST.map(item => ` - ${item}`).join('\n'),
'[Hook] Heuristic signals:',
signalLines,
].join('\n');
}
function run(inputOrRaw) {
let input;
let rawInput = inputOrRaw;
try {
if (typeof inputOrRaw === 'string') {
rawInput = inputOrRaw;
input = inputOrRaw.trim() ? JSON.parse(inputOrRaw) : {};
} else {
input = inputOrRaw || {};
rawInput = JSON.stringify(inputOrRaw ?? {});
}
} catch {
return { exitCode: 0, stdout: typeof rawInput === 'string' ? rawInput : '' };
}
const filePaths = getFilePaths(input);
const frontendPaths = filePaths.filter(filePath => FRONTEND_EXTENSIONS.test(filePath));
if (frontendPaths.length === 0) {
return { exitCode: 0, stdout: typeof rawInput === 'string' ? rawInput : '' };
}
const findings = [];
for (const filePath of frontendPaths) {
const content = readContent(filePath);
findings.push(...detectSignals(content));
}
return {
exitCode: 0,
stdout: typeof rawInput === 'string' ? rawInput : '',
stderr: buildWarning(frontendPaths, findings),
};
}
module.exports = { run };
if (require.main === module) {
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const result = run(raw);
if (result.stderr) process.stderr.write(`${result.stderr}\n`);
process.stdout.write(typeof result.stdout === 'string' ? result.stdout : raw);
process.exit(Number.isInteger(result.exitCode) ? result.exitCode : 0);
});
}
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env node
/**
* Desktop Notification Hook (Stop)
*
* Sends a native desktop notification with the task summary when Claude
* finishes responding. Supports:
* - macOS: iTerm2 native escape sequence (preferred) or osascript (fallback)
* - WSL: PowerShell 7 or Windows PowerShell + BurntToast module
*
* On macOS under iTerm2, the notification is owned by iTerm2; clicking it
* focuses the iTerm2 tab where Claude Code runs. Outside iTerm2, falls back
* to osascript (notification owned by Script Editor; clicks launch it).
*
* On WSL, if BurntToast is not installed, logs a tip for installation.
*
* Hook ID : stop:desktop-notify
* Profiles: standard, strict
*/
'use strict';
const { spawnSync, execFileSync } = require('child_process');
const fs = require('fs');
const { isMacOS, log } = require('../lib/utils');
const TITLE = 'Claude Code';
const MAX_BODY_LENGTH = 100;
const MAX_TTY_LOOKUP_DEPTH = 30;
const PS_TIMEOUT_MS = 2000;
/**
* Memoized WSL detection at module load (avoids repeated /proc/version reads).
*/
let isWSL = false;
if (process.platform === 'linux') {
try {
isWSL = fs.readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft');
} catch {
isWSL = false;
}
}
/**
* Find available PowerShell executable on WSL.
* Returns first accessible path, or null if none found.
*/
function findPowerShell() {
if (!isWSL) return null;
const candidates = [
'pwsh.exe', // WSL interop resolves from Windows PATH
'powershell.exe', // WSL interop for Windows PowerShell
'/mnt/c/Program Files/PowerShell/7/pwsh.exe', // PowerShell 7 (default install)
'/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe', // Windows PowerShell
];
for (const path of candidates) {
try {
const result = spawnSync(path, ['-Command', 'exit 0'],
{ stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 });
if (result.status === 0) {
return path;
}
} catch {
// continue
}
}
return null;
}
/**
* Send a Windows Toast notification via PowerShell BurntToast.
* Returns { success: boolean, reason: string|null }.
* reason is null on success, or contains error detail on failure.
*/
function notifyWindows(pwshPath, title, body) {
const safeBody = body.replace(/'/g, "''");
const safeTitle = title.replace(/'/g, "''");
const command = `Import-Module BurntToast; New-BurntToastNotification -Text '${safeTitle}', '${safeBody}'`;
const result = spawnSync(pwshPath, ['-Command', command],
{ stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 });
if (result.status === 0) {
return { success: true, reason: null };
}
const errorMsg = result.error ? result.error.message : result.stderr?.toString();
return { success: false, reason: errorMsg || `exit ${result.status}` };
}
/**
* Extract a short summary from the last assistant message.
* Takes the first non-empty line and truncates to MAX_BODY_LENGTH chars.
*/
function extractSummary(message) {
if (!message || typeof message !== 'string') return 'Done';
const firstLine = message
.split('\n')
.map(l => l.trim())
.find(l => l.length > 0);
if (!firstLine) return 'Done';
return firstLine.length > MAX_BODY_LENGTH
? `${firstLine.slice(0, MAX_BODY_LENGTH)}...`
: firstLine;
}
/**
* Walk up the process tree to find an ancestor attached to a real TTY.
* Hook subprocesses are detached from a controlling terminal, but the parent
* Claude Code process still owns the terminal emulator's tty (e.g. iTerm2 tab).
* Returns absolute path like "/dev/ttys017", or null if none found.
*/
function findTerminalTTY() {
let pid = process.pid;
for (let depth = 0; depth < MAX_TTY_LOOKUP_DEPTH; depth += 1) {
try {
const out = execFileSync('ps', ['-o', 'ppid=,tty=', '-p', String(pid)], {
stdio: ['ignore', 'pipe', 'ignore'],
timeout: PS_TIMEOUT_MS,
}).toString().trim();
const m = out.match(/^\s*(\d+)\s+(\S+)\s*$/);
if (!m) return null;
const [, ppidStr, tty] = m;
if (tty && !tty.startsWith('?')) {
// `ps -o tty=` may emit either "ttys001" or the short form "s001"
// depending on macOS version; normalize so the resulting path exists.
const name = tty.startsWith('tty') ? tty : `tty${tty}`;
return `/dev/${name}`;
}
const ppid = parseInt(ppidStr, 10);
if (!ppid || ppid <= 1) return null;
pid = ppid;
} catch {
return null;
}
}
return null;
}
/**
* Detect whether the process runs under a terminal multiplexer that would
* swallow OSC 9. tmux and screen don't pass OSC 9 through by default, so the
* sequence written to their pty never reaches iTerm2 and the user gets no
* notification. In that case we skip the iTerm2 fast path and let osascript
* handle the notification instead.
*/
function isUnderMultiplexer() {
if (process.env.TMUX) return true;
const term = process.env.TERM || '';
return /^screen/.test(term) || /^tmux/.test(term);
}
/**
* Send a macOS notification.
*
* On terminals that support the OSC 9 notification sequence (iTerm2 and
* Ghostty), and when not inside tmux/screen, prefers the native escape
* sequence (ESC ] 9 ; <message> BEL) written to the parent terminal's tty.
* This makes the terminal the notification owner, so clicking the
* notification focuses the exact tab/window where Claude Code is running.
* The default osascript path makes Script Editor the owner instead, which
* causes clicks to launch Script Editor.
*
* Falls back to osascript when not running under an OSC 9-capable terminal,
* when tty discovery fails, or when running inside a multiplexer that would
* swallow OSC 9.
* AppleScript strings do not support backslash escapes, so we replace double
* quotes with curly quotes and strip backslashes before embedding.
*/
function notifyMacOS(title, body) {
const osc9Capable =
process.env.TERM_PROGRAM === 'iTerm.app' ||
process.env.TERM_PROGRAM === 'ghostty';
if (osc9Capable && !isUnderMultiplexer()) {
try {
const tty = findTerminalTTY();
if (tty) {
// Strip control chars (incl. ESC/BEL) to prevent escape-sequence injection.
// eslint-disable-next-line no-control-regex
const message = `${title}: ${body}`.replace(/[\x00-\x1f\x7f]/g, ' ');
fs.writeFileSync(tty, `\x1b]9;${message}\x07`);
return;
}
} catch (err) {
log(`[DesktopNotify] iTerm escape failed, falling back to osascript: ${err.message}`);
}
}
const safeBody = body.replace(/\\/g, '').replace(/"/g, '\u201C');
const safeTitle = title.replace(/\\/g, '').replace(/"/g, '\u201C');
const script = `display notification "${safeBody}" with title "${safeTitle}"`;
const result = spawnSync('osascript', ['-e', script], { stdio: 'ignore', timeout: 5000 });
if (result.error || result.status !== 0) {
log(`[DesktopNotify] osascript failed: ${result.error ? result.error.message : `exit ${result.status}`}`);
}
}
/**
* Fast-path entry point for run-with-flags.js (avoids extra process spawn).
*/
function run(raw) {
try {
const input = raw.trim() ? JSON.parse(raw) : {};
const summary = extractSummary(input.last_assistant_message);
if (isMacOS) {
notifyMacOS(TITLE, summary);
} else if (isWSL) {
const ps = findPowerShell();
if (ps) {
const { success, reason } = notifyWindows(ps, TITLE, summary);
if (success) {
// notification sent successfully
} else if (reason && reason.toLowerCase().includes('burnttoast')) {
// BurntToast module not found
log('[DesktopNotify] Tip: Install BurntToast module to enable notifications');
} else if (reason) {
// Other PowerShell/notification error - log for debugging
log(`[DesktopNotify] Notification failed: ${reason}`);
}
} else {
// No PowerShell found
log('[DesktopNotify] Tip: Install BurntToast module in PowerShell for notifications');
}
}
} catch (err) {
log(`[DesktopNotify] Error: ${err.message}`);
}
return raw;
}
module.exports = { run };
// Legacy stdin path (when invoked directly rather than via run-with-flags)
if (require.main === module) {
const MAX_STDIN = 1024 * 1024;
let data = '';
let truncated = false;
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += chunk.substring(0, remaining);
if (chunk.length > remaining) truncated = true;
} else {
truncated = true;
}
});
process.stdin.on('end', () => {
const output = run(data);
// Never echo truncated stdin — invalid JSON on stdout is reported as a
// Stop hook failure (#2090).
if (truncated) {
log('[DesktopNotify] stdin exceeded 1MB; suppressing pass-through (fail-open)');
return;
}
if (output) process.stdout.write(output);
});
}
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env node
/**
* Doc file warning hook (PreToolUse - Write)
*
* Uses a denylist approach: only warn on known ad-hoc documentation
* filenames (NOTES, TODO, SCRATCH, etc.) outside structured directories.
* This avoids false positives for legitimate markdown-heavy workflows
* (specs, ADRs, command definitions, skill files, etc.).
*
* Policy ported from the intent of PR #962 into the current hook architecture.
* Exit code 0 always (warns only, never blocks).
*/
'use strict';
const path = require('path');
const { buildPreToolUseAdditionalContext } = require('./pretooluse-visible-output');
const MAX_STDIN = 1024 * 1024;
// Known ad-hoc filenames that indicate impulse/scratch files (case-sensitive, uppercase only)
const ADHOC_FILENAMES = /^(NOTES|TODO|SCRATCH|TEMP|DRAFT|BRAINSTORM|SPIKE|DEBUG|WIP)\.(md|txt)$/;
// Structured directories where even ad-hoc names are intentional
const STRUCTURED_DIRS = /(^|\/)(docs|\.claude|\.github|commands|skills|benchmarks|templates|\.history|memory)\//;
function isSuspiciousDocPath(filePath) {
const normalized = filePath.replace(/\\/g, '/');
const basename = path.basename(normalized);
// Only inspect .md and .txt files (case-sensitive, consistent with ADHOC_FILENAMES)
if (!/\.(md|txt)$/.test(basename)) return false;
// Only flag known ad-hoc filenames
if (!ADHOC_FILENAMES.test(basename)) return false;
// Allow ad-hoc names inside structured directories (intentional usage)
if (STRUCTURED_DIRS.test(normalized)) return false;
return true;
}
/**
* Exportable run() for in-process execution via run-with-flags.js.
* Avoids the ~50-100ms spawnSync overhead when available.
*/
function run(inputOrRaw, _options = {}) {
let input;
try {
input = typeof inputOrRaw === 'string'
? (inputOrRaw.trim() ? JSON.parse(inputOrRaw) : {})
: (inputOrRaw || {});
} catch {
return { exitCode: 0 };
}
const filePath = String(input?.tool_input?.file_path || '');
if (filePath && isSuspiciousDocPath(filePath)) {
return {
exitCode: 0,
additionalContext: [
'[Hook] WARNING: Ad-hoc documentation filename detected',
`[Hook] File: ${filePath}`,
'[Hook] Consider using a structured path (e.g. docs/, .claude/, skills/, .github/, benchmarks/, templates/)',
],
};
}
return { exitCode: 0 };
}
/**
* Stdin entrypoint for direct/spawnSync execution: reads the hook payload from
* stdin (capped at MAX_STDIN), runs the policy, and writes the PreToolUse result
* to stdout. Must only run when invoked directly, never on require(), so the
* stdin listeners are not leaked into a parent that loads this hook in-process.
*/
function main() {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', c => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += c.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const result = run(data);
if (result.stderr) {
process.stderr.write(result.stderr + '\n');
}
if (Object.prototype.hasOwnProperty.call(result, 'additionalContext')) {
process.stdout.write(buildPreToolUseAdditionalContext(result.additionalContext));
} else {
process.stdout.write(data);
}
});
}
module.exports = { run, main };
// Stdin fallback for spawnSync execution — only when invoked directly, not via require()
if (require.main === module) {
main();
}
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env node
/**
* ECC Context Monitor — PostToolUse hook
*
* Reads bridge file from ecc-metrics-bridge.js and injects agent-facing
* warnings when thresholds are crossed: context exhaustion, high cost,
* scope creep, or tool loops.
*/
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { sanitizeSessionId, readBridge, renameWithRetry } = require('../lib/session-bridge');
const CONTEXT_WARNING_PCT = 35;
const CONTEXT_CRITICAL_PCT = 25;
const COST_NOTICE_USD = 5;
const COST_WARNING_USD = 10;
const COST_CRITICAL_USD = 50;
const FILES_WARNING_COUNT = 20;
const LOOP_THRESHOLD = 3;
const STALE_SECONDS = 60;
function isEnabledEnv(value, defaultValue = true) {
if (value === undefined || value === null || String(value).trim() === '') {
return defaultValue;
}
const normalized = String(value).trim().toLowerCase();
if (['0', 'false', 'no', 'off', 'disabled'].includes(normalized)) return false;
if (['1', 'true', 'yes', 'on', 'enabled'].includes(normalized)) return true;
return defaultValue;
}
function costWarningsEnabled(env = process.env) {
return isEnabledEnv(env.ECC_CONTEXT_MONITOR_COST_WARNINGS, true);
}
/**
* Get debounce state file path.
* @param {string} sessionId
* @returns {string}
*/
function getWarnPath(sessionId) {
return path.join(os.tmpdir(), `ecc-ctx-warn-${sessionId}.json`);
}
/**
* Read debounce state.
* @param {string} sessionId
* @returns {object}
*/
function readWarnState(sessionId) {
try {
return JSON.parse(fs.readFileSync(getWarnPath(sessionId), 'utf8'));
} catch {
return { callsSinceWarn: 0, lastSeverity: null, lastMessage: null };
}
}
/**
* Write debounce state atomically (unique-suffix tmp then rename).
*
* The tmp path includes `process.pid` plus a random nonce so concurrent
* PostToolUse subprocesses writing to the same session's warn-state
* file do not clobber each other's tmp mid-write. Without the unique
* suffix, two writers race over a shared `${target}.tmp` and produce
* either a corrupted payload or an ENOENT throw on the second rename.
*
* Same pattern as `writeBridgeAtomic` in `scripts/lib/session-bridge.js`
* and `writeCostWarningIfChanged` in `scripts/hooks/ecc-metrics-bridge.js`.
*
* @param {string} sessionId
* @param {object} state
*/
function writeWarnState(sessionId, state) {
const target = getWarnPath(sessionId);
const tmp = `${target}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(state), 'utf8');
try {
renameWithRetry(tmp, target);
} catch (err) {
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
throw err;
}
}
/**
* Detect tool loops from recent_tools ring buffer.
* @param {Array} recentTools
* @returns {{detected: boolean, tool: string, count: number}}
*/
function detectLoop(recentTools) {
if (!Array.isArray(recentTools) || recentTools.length < LOOP_THRESHOLD) {
return { detected: false, tool: '', count: 0 };
}
const counts = {};
for (const entry of recentTools) {
const key = `${entry.tool}:${entry.hash}`;
counts[key] = (counts[key] || 0) + 1;
}
for (const [key, count] of Object.entries(counts)) {
if (count >= LOOP_THRESHOLD) {
return { detected: true, tool: key.split(':')[0], count };
}
}
return { detected: false, tool: '', count: 0 };
}
/**
* Evaluate all warning conditions against bridge data.
* Returns array of {severity, type, message} sorted by severity desc.
*/
function evaluateConditions(bridge, options = {}) {
const warnings = [];
const remaining = bridge.context_remaining_pct;
// Context warnings (skip if no context data)
if (remaining !== null && remaining !== undefined) {
if (remaining <= CONTEXT_CRITICAL_PCT) {
warnings.push({
severity: 3,
type: 'context',
message:
`CONTEXT CRITICAL: ${remaining}% remaining. Context nearly exhausted. ` +
'Inform the user that context is low and ask how they want to proceed. ' +
'Do NOT autonomously save state or write handoff files unless the user asks.'
});
} else if (remaining <= CONTEXT_WARNING_PCT) {
warnings.push({
severity: 2,
type: 'context',
message: `CONTEXT WARNING: ${remaining}% remaining. ` + 'Be aware that context is getting limited. Avoid starting new complex work.'
});
}
}
// Cost warnings
if (options.costWarnings !== false) {
const cost = bridge.total_cost_usd || 0;
if (cost > COST_CRITICAL_USD) {
warnings.push({
severity: 3,
type: 'cost',
message: `COST CRITICAL: session total ~$${cost.toFixed(2)} (over $${COST_CRITICAL_USD}). Informational only — not an instruction to stop.`
});
} else if (cost > COST_WARNING_USD) {
warnings.push({
severity: 2,
type: 'cost',
message: `COST WARNING: session total ~$${cost.toFixed(2)} (over $${COST_WARNING_USD}). Informational only.`
});
} else if (cost > COST_NOTICE_USD) {
warnings.push({
severity: 1,
type: 'cost',
message: `COST NOTICE: session total ~$${cost.toFixed(2)}. Informational only.`
});
}
}
// File scope warning
const fileCount = bridge.files_modified_count || 0;
if (fileCount > FILES_WARNING_COUNT) {
warnings.push({
severity: 2,
type: 'scope',
message: `SCOPE WARNING: ${fileCount} files modified this session. ` + 'Consider whether changes are too scattered.'
});
}
// Loop detection
const loop = detectLoop(bridge.recent_tools);
if (loop.detected) {
warnings.push({
severity: 2,
type: 'loop',
message: `LOOP WARNING: Tool '${loop.tool}' called ${loop.count} times ` + 'with same parameters in last 5 calls. This may indicate a stuck loop.'
});
}
return warnings.sort((a, b) => b.severity - a.severity);
}
/**
* Map numeric severity to label.
*/
function severityLabel(n) {
if (n >= 3) return 'critical';
if (n >= 2) return 'warning';
return 'notice';
}
/**
* @param {string} rawInput - Raw JSON string from stdin
* @returns {string} JSON output with additionalContext or pass-through
*/
function run(rawInput) {
try {
const input = rawInput.trim() ? JSON.parse(rawInput) : {};
const sessionId = sanitizeSessionId(input.session_id) || sanitizeSessionId(process.env.ECC_SESSION_ID) || sanitizeSessionId(process.env.CLAUDE_SESSION_ID);
if (!sessionId) return rawInput;
const bridge = readBridge(sessionId);
if (!bridge) return rawInput;
// Stale check for context warnings
const now = Math.floor(Date.now() / 1000);
const lastTs = bridge.last_timestamp ? Math.floor(new Date(bridge.last_timestamp).getTime() / 1000) : 0;
const isStale = lastTs > 0 && now - lastTs > STALE_SECONDS;
// If bridge is stale, null out context data (still check cost/scope/loop)
const evalBridge = isStale ? { ...bridge, context_remaining_pct: null } : bridge;
const warnings = evaluateConditions(evalBridge, { costWarnings: costWarningsEnabled() });
if (warnings.length === 0) {
// Clear dedupe state when the condition resolves, so the SAME warning text
// recurring later (context dips, recovers, dips again; a loop that stops
// then restarts) is surfaced again instead of being suppressed as a
// duplicate. Only write when there is state to clear — most tool calls
// have no warning, and this keeps the common path free of disk writes.
const prior = readWarnState(sessionId);
if (prior.lastMessage) {
writeWarnState(sessionId, { callsSinceWarn: 0, lastSeverity: null, lastMessage: null });
}
return rawInput;
}
// Combine top 2 warnings
const message = warnings
.slice(0, 2)
.map(w => w.message)
.join('\n');
// Dedupe on message content, not a call counter. The previous logic
// re-emitted the *same* warning every DEBOUNCE_CALLS tool calls, so a
// single unchanged condition (e.g. a cost figure that only refreshes at
// turn boundaries) printed the identical line ~20 times in one turn. Now a
// warning is surfaced only when its text changes (cost moved, a new file
// count, a new loop) or when we newly escalate to critical — genuinely new
// information — and is otherwise suppressed.
const warnState = readWarnState(sessionId);
const topSeverity = severityLabel(warnings[0].severity);
const escalatedToCritical = topSeverity === 'critical' && warnState.lastSeverity !== 'critical';
const sameMessage = warnState.lastMessage === message;
if (sameMessage && !escalatedToCritical) {
return rawInput;
}
warnState.lastSeverity = topSeverity;
warnState.lastMessage = message;
writeWarnState(sessionId, warnState);
const output = {
hookSpecificOutput: {
hookEventName: 'PostToolUse',
additionalContext: message
}
};
return JSON.stringify(output);
} catch {
// Never block tool execution
return rawInput;
}
}
if (require.main === module) {
let data = '';
const MAX_STDIN = 1024 * 1024;
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) data += chunk.substring(0, MAX_STDIN - data.length);
});
process.stdin.on('end', () => {
process.stdout.write(run(data));
process.exit(0);
});
}
module.exports = { run, evaluateConditions, detectLoop, severityLabel, costWarningsEnabled };
+284
View File
@@ -0,0 +1,284 @@
#!/usr/bin/env node
/**
* ECC Metrics Bridge — PostToolUse hook
*
* Maintains a running session aggregate in /tmp/ecc-metrics-{session}.json.
* This bridge file is read by ecc-statusline.js and ecc-context-monitor.js,
* avoiding the need to scan large JSONL logs on every invocation.
*/
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge');
const { getClaudeDir } = require('../lib/utils');
const MAX_STDIN = 1024 * 1024;
const MAX_FILES_TRACKED = 200;
const RECENT_TOOLS_SIZE = 5;
const HASH_INPUT_LIMIT = 2048;
const WARNING_CACHE_PREFIX = 'ecc-metrics-cost-warnings-';
function toNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
function stableStringify(value, depth = 0) {
if (depth > 4) return '[depth-limit]';
if (value === null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) {
return `[${value.map(item => stableStringify(item, depth + 1)).join(',')}]`;
}
return `{${Object.keys(value)
.sort()
.map(key => `${JSON.stringify(key)}:${stableStringify(value[key], depth + 1)}`)
.join(',')}}`;
}
/**
* Hash tool call for loop detection.
* Uses tool name + a key parameter when available, otherwise a stable input digest.
*/
function hashToolCall(toolName, toolInput) {
const name = String(toolName || '');
let key = '';
if (name === 'Bash') {
key = String(toolInput?.command || '').slice(0, 160);
} else if (/^(Edit|MultiEdit|Write|NotebookEdit)$/.test(name)) {
// Fingerprint the actual change, not just the path. Hashing on file_path
// alone made every distinct edit to the same file collide, so a few normal
// edits to one file looked like a stuck loop. Include the edit content so
// different edits to the same file hash differently.
// Hash the FULL serialized payload (truncate the digest, not the input):
// slicing the serialized string to HASH_INPUT_LIMIT first would collapse
// large edits that share their first N chars, reviving the same false-loop
// collision for big Write/edit payloads.
key = crypto
.createHash('sha256')
.update(
stableStringify({
file_path: toolInput?.file_path,
old_string: toolInput?.old_string,
new_string: toolInput?.new_string,
content: toolInput?.content,
edits: toolInput?.edits
})
)
.digest('hex');
} else if (toolInput?.file_path) {
key = String(toolInput.file_path);
} else {
key = stableStringify(toolInput || {}).slice(0, HASH_INPUT_LIMIT);
}
return crypto.createHash('sha256').update(`${name}:${key}`).digest('hex').slice(0, 8);
}
/**
* Extract modified file paths from tool input.
*/
function extractFilePaths(toolName, toolInput) {
const paths = [];
if (!toolInput || typeof toolInput !== 'object') return paths;
const fp = toolInput.file_path;
if (fp && typeof fp === 'string') paths.push(fp);
const edits = toolInput.edits;
if (Array.isArray(edits)) {
for (const edit of edits) {
if (edit?.file_path && typeof edit.file_path === 'string') {
paths.push(edit.file_path);
}
}
}
return paths;
}
function getCostWarningCachePath(costsPath) {
const hash = crypto.createHash('sha256').update(costsPath).digest('hex').slice(0, 16);
return path.join(os.tmpdir(), `${WARNING_CACHE_PREFIX}${hash}.json`);
}
function readCostWarningCache(cachePath) {
try {
const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
function writeCostWarningIfChanged(kind, costsPath, signature, message) {
const cachePath = getCostWarningCachePath(costsPath);
const cache = readCostWarningCache(cachePath);
if (cache[kind] === signature) return;
process.stderr.write(message);
try {
const next = { ...cache, [kind]: signature };
const tmp = `${cachePath}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(next), 'utf8');
fs.renameSync(tmp, cachePath);
} catch {
// Warning-cache persistence is best effort; never block hook execution.
}
}
/**
* Read cumulative cost for a session from costs.jsonl.
*
* Scans the full file because each row is a cumulative session total
* (see cost-tracker.js docblock) and the row we need is the last one
* matching `sessionId`. The previous implementation read only the
* trailing 8 KiB; any session whose latest cumulative row was pushed
* past that window by newer rows from other sessions silently dropped
* to zero — the opposite sign of the double-count bug fixed in the
* previous commit.
*
* costs.jsonl is append-only and unbounded today (no rotation in
* cost-tracker.js). At a typical ~150 bytes per row, even 100k rows
* is ~15 MB and a single sync read on every PostToolUse hook is in
* the low milliseconds. If rotation lands later, this scan becomes
* even cheaper.
*/
function readSessionCost(sessionId) {
let costsPath = path.join('metrics', 'costs.jsonl');
try {
costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl');
const content = fs.readFileSync(costsPath, 'utf8');
const lines = content.split('\n').filter(Boolean);
let totalCost = 0;
let totalIn = 0;
let totalOut = 0;
let malformed = 0;
const malformedHasher = crypto.createHash('sha256');
for (const line of lines) {
try {
const row = JSON.parse(line);
if (row.session_id === sessionId) {
totalCost = toNumber(row.estimated_cost_usd);
totalIn = toNumber(row.input_tokens);
totalOut = toNumber(row.output_tokens);
}
} catch {
malformed += 1;
malformedHasher.update(line).update('\0');
}
}
// One aggregated breadcrumb per call rather than one per bad row, so a
// log-flooded costs.jsonl stays diagnosable without overwhelming stderr.
// Suppress repeats for the same malformed-line signature across hook
// subprocesses, so a persistent bad row should not spam stderr.
if (malformed > 0) {
writeCostWarningIfChanged(
'malformed',
costsPath,
`${malformed}:${malformedHasher.digest('hex').slice(0, 16)}`,
`[ecc-metrics-bridge] skipped ${malformed} malformed line(s) in ${costsPath}\n`
);
}
return { totalCost, totalIn, totalOut };
} catch (err) {
// ENOENT is the common case (no Stop event has fired yet this session)
// and is not actually a failure — stay silent on it. Anything else
// (permission, EISDIR, malformed read) deserves a breadcrumb because
// the bridge will silently report zero cost otherwise.
if (err && err.code !== 'ENOENT') {
writeCostWarningIfChanged(
'read-error',
costsPath,
`${err.code || err.name || 'error'}:${err.message || String(err)}`,
`[ecc-metrics-bridge] failing open after ${err.name || 'error'} reading ${costsPath}: ${err.message || String(err)}\n`
);
}
return { totalCost: 0, totalIn: 0, totalOut: 0 };
}
}
/**
* @param {string} rawInput - Raw JSON string from stdin
* @returns {string} Pass-through
*/
function run(rawInput) {
try {
const input = rawInput.trim() ? JSON.parse(rawInput) : {};
const toolName = String(input.tool_name || '');
const toolInput = input.tool_input || {};
const sessionId = sanitizeSessionId(input.session_id) || sanitizeSessionId(process.env.ECC_SESSION_ID) || sanitizeSessionId(process.env.CLAUDE_SESSION_ID);
if (!sessionId) return rawInput;
const now = new Date().toISOString();
const bridge = readBridge(sessionId) || {
session_id: sessionId,
total_cost_usd: 0,
total_input_tokens: 0,
total_output_tokens: 0,
tool_count: 0,
files_modified_count: 0,
files_modified: [],
recent_tools: [],
first_timestamp: now,
last_timestamp: now,
context_remaining_pct: null
};
// Increment tool count
bridge.tool_count = (bridge.tool_count || 0) + 1;
bridge.last_timestamp = now;
if (!bridge.first_timestamp) bridge.first_timestamp = now;
// Track modified files (Write/Edit/MultiEdit only)
const isWriteOp = /^(Write|Edit|MultiEdit)$/i.test(toolName);
if (isWriteOp) {
const newPaths = extractFilePaths(toolName, toolInput);
const existing = new Set(bridge.files_modified || []);
for (const p of newPaths) {
if (existing.size < MAX_FILES_TRACKED && !existing.has(p)) {
existing.add(p);
}
}
bridge.files_modified = [...existing];
bridge.files_modified_count = existing.size;
}
// Ring buffer for loop detection
const recent = bridge.recent_tools || [];
recent.push({ tool: toolName, hash: hashToolCall(toolName, toolInput) });
if (recent.length > RECENT_TOOLS_SIZE) recent.shift();
bridge.recent_tools = recent;
// Update cost from costs.jsonl tail
const costs = readSessionCost(sessionId);
bridge.total_cost_usd = Math.round(costs.totalCost * 1e6) / 1e6;
bridge.total_input_tokens = costs.totalIn;
bridge.total_output_tokens = costs.totalOut;
writeBridgeAtomic(sessionId, bridge);
} catch {
// Never block tool execution
}
return rawInput;
}
if (require.main === module) {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) data += chunk.substring(0, MAX_STDIN - data.length);
});
process.stdin.on('end', () => {
process.stdout.write(run(data));
process.exit(0);
});
}
module.exports = { run, hashToolCall, extractFilePaths, readSessionCost, stableStringify };
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env node
/**
* ECC Statusline — statusLine command
*
* Displays: model | task | $cost Nt Nf Nm | dir ██░░ N%
*
* Registered in settings.json under "statusLine", not in hooks.json.
* Reads bridge file from ecc-metrics-bridge.js and stdin from Claude Code runtime.
*/
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge');
const AUTO_COMPACT_BUFFER_PCT = 16.5;
const MAX_STDIN = 1024 * 1024;
/**
* Format duration from ISO timestamp to now.
* @param {string} isoTimestamp
* @returns {string} e.g. "5s", "12m", "1h23m"
*/
function formatDuration(isoTimestamp) {
if (!isoTimestamp) return '?';
const elapsed = Math.floor((Date.now() - new Date(isoTimestamp).getTime()) / 1000);
if (elapsed < 0) return '?';
if (elapsed < 60) return `${elapsed}s`;
const mins = Math.floor(elapsed / 60);
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
const remMins = mins % 60;
return remMins > 0 ? `${hours}h${remMins}m` : `${hours}h`;
}
/**
* Build context progress bar with ANSI colors.
* @param {number} remaining - Raw remaining percentage from Claude Code
* @returns {string} Colored bar string
*/
function buildContextBar(remaining) {
if (remaining === null || remaining === undefined) return '';
const usableRemaining = Math.max(0, ((remaining - AUTO_COMPACT_BUFFER_PCT) / (100 - AUTO_COMPACT_BUFFER_PCT)) * 100);
const used = Math.max(0, Math.min(100, Math.round(100 - usableRemaining)));
const filled = Math.floor(used / 10);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled);
if (used < 50) return ` \x1b[32m${bar} ${used}%\x1b[0m`;
if (used < 65) return ` \x1b[33m${bar} ${used}%\x1b[0m`;
if (used < 80) return ` \x1b[38;5;208m${bar} ${used}%\x1b[0m`;
return ` \x1b[1;31m${bar} ${used}%\x1b[0m`;
}
/**
* Read current in-progress task from todos directory.
* @param {string} sessionId
* @returns {string} Task activeForm text or empty string
*/
function readCurrentTask(sessionId) {
try {
const safeSessionId = sanitizeSessionId(sessionId);
if (!safeSessionId) return '';
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const todosDir = path.join(claudeDir, 'todos');
if (!fs.existsSync(todosDir)) return '';
const files = fs
.readdirSync(todosDir)
.filter(f => f.startsWith(safeSessionId) && f.includes('-agent-') && f.endsWith('.json'))
.map(f => ({ name: f, mtime: fs.statSync(path.join(todosDir, f)).mtime }))
.sort((a, b) => b.mtime - a.mtime);
if (files.length === 0) return '';
const todos = JSON.parse(fs.readFileSync(path.join(todosDir, files[0].name), 'utf8'));
const inProgress = todos.find(t => t.status === 'in_progress');
return inProgress?.activeForm || '';
} catch {
return '';
}
}
function runStatusline() {
let input = '';
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (input.length < MAX_STDIN) {
input += chunk.substring(0, MAX_STDIN - input.length);
}
});
process.stdin.on('end', () => {
clearTimeout(stdinTimeout);
try {
const data = JSON.parse(input);
const model = data.model?.display_name || 'Claude';
const dir = data.workspace?.current_dir || process.cwd();
const session = data.session_id || '';
const remaining = data.context_window?.remaining_percentage;
const sessionId = sanitizeSessionId(session);
const bridge = sessionId ? readBridge(sessionId) : null;
// Write context % back to bridge for context-monitor
if (sessionId && bridge && remaining !== null && remaining !== undefined) {
bridge.context_remaining_pct = remaining;
try {
writeBridgeAtomic(sessionId, bridge);
} catch {
/* best effort */
}
}
// Current task
const task = sessionId ? readCurrentTask(sessionId) : '';
// Metrics from bridge
let metricsStr = '';
if (bridge) {
const parts = [];
if (bridge.total_cost_usd > 0) {
parts.push(`$${bridge.total_cost_usd.toFixed(2)}`);
}
if (bridge.tool_count > 0) {
parts.push(`${bridge.tool_count}t`);
}
if (bridge.files_modified_count > 0) {
parts.push(`${bridge.files_modified_count}f`);
}
const dur = formatDuration(bridge.first_timestamp);
if (dur !== '?') {
parts.push(dur);
}
if (parts.length > 0) {
metricsStr = `\x1b[38;5;117m${parts.join(' ')}\x1b[0m`;
}
}
// Context bar
const ctx = buildContextBar(remaining);
// Build output
const dirname = path.basename(dir);
const segments = [`\x1b[2m${model}\x1b[0m`];
if (task) {
segments.push(`\x1b[1;97m${task}\x1b[0m`);
}
if (metricsStr) {
segments.push(metricsStr);
}
segments.push(`\x1b[2m${dirname}\x1b[0m`);
process.stdout.write(segments.join(' \x1b[2m\u2502\x1b[0m ') + ctx);
} catch {
// Silent fail
}
});
}
module.exports = { formatDuration, buildContextBar, readCurrentTask, MAX_STDIN };
if (require.main === module) runStatusline();
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env node
/**
* Continuous Learning - Session Evaluator
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs on Stop hook to extract reusable patterns from Claude Code sessions.
* Reads transcript_path from stdin JSON (Claude Code hook input).
*
* Why Stop hook instead of UserPromptSubmit:
* - Stop runs once at session end (lightweight)
* - UserPromptSubmit runs every message (heavy, adds latency)
*/
const path = require('path');
const fs = require('fs');
const {
getLearnedSkillsDir,
ensureDir,
readFile,
countInFile,
log
} = require('../lib/utils');
// Read hook input from stdin (Claude Code provides transcript_path via stdin JSON)
const MAX_STDIN = 1024 * 1024;
let stdinData = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (stdinData.length < MAX_STDIN) {
const remaining = MAX_STDIN - stdinData.length;
stdinData += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
main().catch(err => {
console.error('[ContinuousLearning] Error:', err.message);
process.exit(0);
});
});
async function main() {
// Parse stdin JSON to get transcript_path
let transcriptPath = null;
try {
const input = JSON.parse(stdinData);
transcriptPath = input.transcript_path;
} catch {
// Fallback: try env var for backwards compatibility
transcriptPath = process.env.CLAUDE_TRANSCRIPT_PATH;
}
// Get script directory to find config
const scriptDir = __dirname;
const configFile = path.join(scriptDir, '..', '..', 'skills', 'continuous-learning', 'config.json');
// Default configuration
let minSessionLength = 10;
let learnedSkillsPath = getLearnedSkillsDir();
// Load config if exists
const configContent = readFile(configFile);
if (configContent) {
try {
const config = JSON.parse(configContent);
minSessionLength = config.min_session_length ?? 10;
if (config.learned_skills_path) {
// Handle ~ in path
learnedSkillsPath = config.learned_skills_path.replace(/^~/, require('os').homedir());
}
} catch (err) {
log(`[ContinuousLearning] Failed to parse config: ${err.message}, using defaults`);
}
}
// Ensure learned skills directory exists
ensureDir(learnedSkillsPath);
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
process.exit(0);
}
// Count user messages in session (allow optional whitespace around colon)
const messageCount = countInFile(transcriptPath, /"type"\s*:\s*"user"/g);
// Skip short sessions
if (messageCount < minSessionLength) {
log(`[ContinuousLearning] Session too short (${messageCount} messages), skipping`);
process.exit(0);
}
// Signal to Claude that session should be evaluated for extractable patterns
log(`[ContinuousLearning] Session has ${messageCount} messages - evaluate for extractable patterns`);
log(`[ContinuousLearning] Save learned skills to: ${learnedSkillsPath}`);
process.exit(0);
}
File diff suppressed because it is too large Load Diff
+334
View File
@@ -0,0 +1,334 @@
#!/usr/bin/env node
/**
* Governance Event Capture Hook
*
* PreToolUse/PostToolUse hook that detects governance-relevant events
* and writes them to the governance_events table in the state store.
*
* Captured event types:
* - secret_detected: Hardcoded secrets in tool input/output
* - policy_violation: Actions that violate configured policies
* - security_finding: Security-relevant tool invocations
* - approval_requested: Operations requiring explicit approval
* - hook_input_truncated: Hook input exceeded the safe inspection limit
*
* Enable: Set ECC_GOVERNANCE_CAPTURE=1
* Configure session: Set ECC_SESSION_ID for session correlation
*/
'use strict';
const crypto = require('crypto');
const MAX_STDIN = 1024 * 1024;
// Patterns that indicate potential hardcoded secrets
const SECRET_PATTERNS = [
{ name: 'aws_key', pattern: /(?:AKIA|ASIA)[A-Z0-9]{16}/i },
{ name: 'generic_secret', pattern: /(?:secret|password|token|api[_-]?key)\s*[:=]\s*["'][^"']{8,}/i },
{ name: 'private_key', pattern: /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/ },
{ name: 'jwt', pattern: /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
{ name: 'github_token', pattern: /gh[pousr]_[A-Za-z0-9_]{36,}/ },
];
// Tool names that represent security-relevant operations
const SECURITY_RELEVANT_TOOLS = new Set([
'Bash', // Could execute arbitrary commands
]);
// Commands that require governance approval
const APPROVAL_COMMANDS = [
/git\s+push\s+.*--force/,
/git\s+reset\s+--hard/,
/rm\s+-rf?\s/,
/DROP\s+(?:TABLE|DATABASE)/i,
/DELETE\s+FROM\s+\w+\s*(?:;|$)/i,
];
// File patterns that indicate policy-sensitive paths
const SENSITIVE_PATHS = [
/\.env(?:\.|$)/,
/credentials/i,
/secrets?\./i,
/\.pem$/,
/\.key$/,
/id_rsa/,
];
/**
* Generate a unique event ID.
*/
function generateEventId() {
return `gov-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
}
/**
* Scan text content for hardcoded secrets.
* Returns array of { name, match } for each detected secret.
*/
function detectSecrets(text) {
if (!text || typeof text !== 'string') return [];
const findings = [];
for (const { name, pattern } of SECRET_PATTERNS) {
if (pattern.test(text)) {
findings.push({ name });
}
}
return findings;
}
/**
* Check if a command requires governance approval.
*/
function detectApprovalRequired(command) {
if (!command || typeof command !== 'string') return [];
const findings = [];
for (const pattern of APPROVAL_COMMANDS) {
if (pattern.test(command)) {
findings.push({ pattern: pattern.source });
}
}
return findings;
}
/**
* Check if a file path is policy-sensitive.
*/
function detectSensitivePath(filePath) {
if (!filePath || typeof filePath !== 'string') return false;
return SENSITIVE_PATHS.some(pattern => pattern.test(filePath));
}
function fingerprintCommand(command) {
if (!command || typeof command !== 'string') return null;
return crypto.createHash('sha256').update(command).digest('hex').slice(0, 12);
}
function summarizeCommand(command) {
if (!command || typeof command !== 'string') {
return {
commandName: null,
commandFingerprint: null,
};
}
const trimmed = command.trim();
if (!trimmed) {
return {
commandName: null,
commandFingerprint: null,
};
}
return {
commandName: trimmed.split(/\s+/)[0] || null,
commandFingerprint: fingerprintCommand(trimmed),
};
}
function emitGovernanceEvent(event) {
process.stderr.write(`[governance] ${JSON.stringify(event)}\n`);
}
/**
* Analyze a hook input payload and return governance events to capture.
*
* @param {Object} input - Parsed hook input (tool_name, tool_input, tool_output)
* @param {Object} [context] - Additional context (sessionId, hookPhase)
* @returns {Array<Object>} Array of governance event objects
*/
function analyzeForGovernanceEvents(input, context = {}) {
const events = [];
const toolName = input.tool_name || '';
const toolInput = input.tool_input || {};
const toolOutput = typeof input.tool_output === 'string' ? input.tool_output : '';
const sessionId = context.sessionId || null;
const hookPhase = context.hookPhase || 'unknown';
// 1. Secret detection in tool input content
const inputText = typeof toolInput === 'object'
? JSON.stringify(toolInput)
: String(toolInput);
const inputSecrets = detectSecrets(inputText);
const outputSecrets = detectSecrets(toolOutput);
const allSecrets = [...inputSecrets, ...outputSecrets];
if (allSecrets.length > 0) {
events.push({
id: generateEventId(),
sessionId,
eventType: 'secret_detected',
payload: {
toolName,
hookPhase,
secretTypes: allSecrets.map(s => s.name),
location: inputSecrets.length > 0 ? 'input' : 'output',
severity: 'critical',
},
resolvedAt: null,
resolution: null,
});
}
// 2. Approval-required commands (Bash only)
if (toolName === 'Bash') {
const command = toolInput.command || '';
const approvalFindings = detectApprovalRequired(command);
const commandSummary = summarizeCommand(command);
if (approvalFindings.length > 0) {
events.push({
id: generateEventId(),
sessionId,
eventType: 'approval_requested',
payload: {
toolName,
hookPhase,
...commandSummary,
matchedPatterns: approvalFindings.map(f => f.pattern),
severity: 'high',
},
resolvedAt: null,
resolution: null,
});
}
}
// 3. Policy violation: writing to sensitive paths
const filePath = toolInput.file_path || toolInput.path || '';
if (filePath && detectSensitivePath(filePath)) {
events.push({
id: generateEventId(),
sessionId,
eventType: 'policy_violation',
payload: {
toolName,
hookPhase,
filePath: filePath.slice(0, 200),
reason: 'sensitive_file_access',
severity: 'warning',
},
resolvedAt: null,
resolution: null,
});
}
// 4. Security-relevant tool usage tracking
if (SECURITY_RELEVANT_TOOLS.has(toolName) && hookPhase === 'post') {
const command = toolInput.command || '';
const hasElevated = /sudo\s/.test(command) || /chmod\s/.test(command) || /chown\s/.test(command);
const commandSummary = summarizeCommand(command);
if (hasElevated) {
events.push({
id: generateEventId(),
sessionId,
eventType: 'security_finding',
payload: {
toolName,
hookPhase,
...commandSummary,
reason: 'elevated_privilege_command',
severity: 'medium',
},
resolvedAt: null,
resolution: null,
});
}
}
return events;
}
/**
* Core hook logic — exported so run-with-flags.js can call directly.
*
* @param {string} rawInput - Raw JSON string from stdin
* @returns {string} The original input (pass-through)
*/
function run(rawInput, options = {}) {
// Gate on feature flag
if (String(process.env.ECC_GOVERNANCE_CAPTURE || '').toLowerCase() !== '1') {
return rawInput;
}
const sessionId = process.env.ECC_SESSION_ID || null;
const hookPhase = process.env.CLAUDE_HOOK_EVENT_NAME || 'unknown';
if (options.truncated) {
emitGovernanceEvent({
id: generateEventId(),
sessionId,
eventType: 'hook_input_truncated',
payload: {
hookPhase: hookPhase.startsWith('Pre') ? 'pre' : 'post',
sizeLimitBytes: options.maxStdin || MAX_STDIN,
severity: 'warning',
},
resolvedAt: null,
resolution: null,
});
}
try {
const input = JSON.parse(rawInput);
const events = analyzeForGovernanceEvents(input, {
sessionId,
hookPhase: hookPhase.startsWith('Pre') ? 'pre' : 'post',
});
if (events.length > 0) {
for (const event of events) {
emitGovernanceEvent(event);
}
}
} catch {
// Silently ignore parse errors — never block the tool pipeline.
}
return rawInput;
}
// ── stdin entry point ────────────────────────────────
if (require.main === module) {
let raw = '';
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
if (chunk.length > remaining) {
truncated = true;
}
} else {
truncated = true;
}
});
process.stdin.on('end', () => {
const result = run(raw, {
truncated,
maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN,
});
process.stdout.write(result);
});
}
module.exports = {
APPROVAL_COMMANDS,
SECRET_PATTERNS,
SECURITY_RELEVANT_TOOLS,
SENSITIVE_PATHS,
analyzeForGovernanceEvents,
detectApprovalRequired,
detectSecrets,
detectSensitivePath,
generateEventId,
run,
};
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""
InsAIts Security Monitor -- PreToolUse Hook for Claude Code
============================================================
Real-time security monitoring for Claude Code tool inputs.
Detects credential exposure, prompt injection, behavioral anomalies,
hallucination chains, and 20+ other anomaly types -- runs 100% locally.
Writes audit events to .insaits_audit_session.jsonl for forensic tracing.
Setup:
pip install insa-its
export ECC_ENABLE_INSAITS=1
Add to .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node scripts/hooks/insaits-security-wrapper.js"
}
]
}
]
}
}
How it works:
Claude Code passes tool input as JSON on stdin.
This script runs InsAIts anomaly detection on the content.
Exit code 0 = clean (pass through).
Exit code 2 = critical issue found (blocks tool execution).
Stderr output = non-blocking warning shown to Claude.
Environment variables:
INSAITS_DEV_MODE Set to "true" to enable dev mode (no API key needed).
Defaults to "false" (strict mode).
INSAITS_MODEL LLM model identifier for fingerprinting. Default: claude-opus.
INSAITS_FAIL_MODE "open" (default) = continue on SDK errors.
"closed" = block tool execution on SDK errors.
INSAITS_VERBOSE Set to any value to enable debug logging.
Detections include:
- Credential exposure (API keys, tokens, passwords)
- Prompt injection patterns
- Hallucination indicators (phantom citations, fact contradictions)
- Behavioral anomalies (context loss, semantic drift)
- Tool description divergence
- Shorthand emergence / jargon drift
All processing is local -- no data leaves your machine.
Author: Cristi Bogdan -- YuyAI (https://github.com/Nomadu27/InsAIts)
License: Apache 2.0
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import sys
import time
from typing import Any, Dict, List, Tuple
# Configure logging to stderr so it does not interfere with stdout protocol
logging.basicConfig(
stream=sys.stderr,
format="[InsAIts] %(message)s",
level=logging.DEBUG if os.environ.get("INSAITS_VERBOSE") else logging.WARNING,
)
log = logging.getLogger("insaits-hook")
# Try importing InsAIts SDK
try:
from insa_its import insAItsMonitor
INSAITS_AVAILABLE: bool = True
except ImportError:
INSAITS_AVAILABLE = False
# --- Constants ---
AUDIT_FILE: str = ".insaits_audit_session.jsonl"
MIN_CONTENT_LENGTH: int = 10
MAX_SCAN_LENGTH: int = 4000
DEFAULT_MODEL: str = "claude-opus"
BLOCKING_SEVERITIES: frozenset = frozenset({"CRITICAL"})
def extract_content(data: Dict[str, Any]) -> Tuple[str, str]:
"""Extract inspectable text from a Claude Code tool input payload.
Returns:
A (text, context) tuple where *text* is the content to scan and
*context* is a short label for the audit log.
"""
tool_name: str = data.get("tool_name", "")
tool_input: Dict[str, Any] = data.get("tool_input", {})
text: str = ""
context: str = ""
if tool_name in ("Write", "Edit", "MultiEdit"):
text = tool_input.get("content", "") or tool_input.get("new_string", "")
context = "file:" + str(tool_input.get("file_path", ""))[:80]
elif tool_name == "Bash":
# PreToolUse: the tool hasn't executed yet, inspect the command
command: str = str(tool_input.get("command", ""))
text = command
context = "bash:" + command[:80]
elif "content" in data:
content: Any = data["content"]
if isinstance(content, list):
text = "\n".join(
b.get("text", "") for b in content if b.get("type") == "text"
)
elif isinstance(content, str):
text = content
context = str(data.get("task", ""))
return text, context
def write_audit(event: Dict[str, Any]) -> None:
"""Append an audit event to the JSONL audit log.
Creates a new dict to avoid mutating the caller's *event*.
"""
try:
enriched: Dict[str, Any] = {
**event,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
enriched["hash"] = hashlib.sha256(
json.dumps(enriched, sort_keys=True).encode()
).hexdigest()[:16]
with open(AUDIT_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(enriched) + "\n")
except OSError as exc:
log.warning("Failed to write audit log %s: %s", AUDIT_FILE, exc)
def get_anomaly_attr(anomaly: Any, key: str, default: str = "") -> str:
"""Get a field from an anomaly that may be a dict or an object.
The SDK's ``send_message()`` returns anomalies as dicts, while
other code paths may return dataclass/object instances. This
helper handles both transparently.
"""
if isinstance(anomaly, dict):
return str(anomaly.get(key, default))
return str(getattr(anomaly, key, default))
def format_feedback(anomalies: List[Any]) -> str:
"""Format detected anomalies as feedback for Claude Code.
Returns:
A human-readable multi-line string describing each finding.
"""
lines: List[str] = [
"== InsAIts Security Monitor -- Issues Detected ==",
"",
]
for i, a in enumerate(anomalies, 1):
sev: str = get_anomaly_attr(a, "severity", "MEDIUM")
atype: str = get_anomaly_attr(a, "type", "UNKNOWN")
detail: str = get_anomaly_attr(a, "details", "")
lines.extend([
f"{i}. [{sev}] {atype}",
f" {detail[:120]}",
"",
])
lines.extend([
"-" * 56,
"Fix the issues above before continuing.",
"Audit log: " + AUDIT_FILE,
])
return "\n".join(lines)
def main() -> None:
"""Entry point for the Claude Code PreToolUse hook."""
raw: str = sys.stdin.read().strip()
if not raw:
sys.exit(0)
try:
data: Dict[str, Any] = json.loads(raw)
except json.JSONDecodeError:
data = {"content": raw}
text, context = extract_content(data)
# Skip very short content (e.g. "OK", empty bash results)
if len(text.strip()) < MIN_CONTENT_LENGTH:
sys.exit(0)
if not INSAITS_AVAILABLE:
log.warning("Not installed. Run: pip install insa-its")
sys.exit(0)
# Wrap SDK calls so an internal error does not crash the hook
try:
monitor: insAItsMonitor = insAItsMonitor(
session_name="claude-code-hook",
dev_mode=os.environ.get(
"INSAITS_DEV_MODE", "false"
).lower() in ("1", "true", "yes"),
)
result: Dict[str, Any] = monitor.send_message(
text=text[:MAX_SCAN_LENGTH],
sender_id="claude-code",
llm_id=os.environ.get("INSAITS_MODEL", DEFAULT_MODEL),
)
except Exception as exc: # Broad catch intentional: unknown SDK internals
fail_mode: str = os.environ.get("INSAITS_FAIL_MODE", "open").lower()
if fail_mode == "closed":
sys.stdout.write(
f"InsAIts SDK error ({type(exc).__name__}); "
"blocking execution to avoid unscanned input.\n"
)
sys.exit(2)
log.warning(
"SDK error (%s), skipping security scan: %s",
type(exc).__name__, exc,
)
sys.exit(0)
anomalies: List[Any] = result.get("anomalies", [])
# Write audit event regardless of findings
write_audit({
"tool": data.get("tool_name", "unknown"),
"context": context,
"anomaly_count": len(anomalies),
"anomaly_types": [get_anomaly_attr(a, "type") for a in anomalies],
"text_length": len(text),
})
if not anomalies:
log.debug("Clean -- no anomalies detected.")
sys.exit(0)
# Determine maximum severity
has_critical: bool = any(
get_anomaly_attr(a, "severity").upper() in BLOCKING_SEVERITIES
for a in anomalies
)
feedback: str = format_feedback(anomalies)
if has_critical:
# stdout feedback -> Claude Code shows to the model
sys.stdout.write(feedback + "\n")
sys.exit(2) # PreToolUse exit 2 = block tool execution
else:
# Non-critical: warn via stderr (non-blocking)
log.warning("\n%s", feedback)
sys.exit(0)
if __name__ == "__main__":
main()
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
/**
* InsAIts Security Monitor - wrapper for run-with-flags compatibility.
*
* This thin wrapper receives stdin from the hooks infrastructure and
* delegates to the Python-based insaits-security-monitor.py script.
*
* The wrapper exists because run-with-flags.js spawns child scripts
* via `node`, so a JS entry point is needed to bridge to Python.
*/
'use strict';
const path = require('path');
const { spawnSync } = require('child_process');
const MAX_STDIN = 1024 * 1024;
const WINDOWS_SHELL_UNSAFE_PATH_CHARS = /[&|<>^%!]/;
function isEnabled(value) {
return ['1', 'true', 'yes', 'on'].includes(String(value || '').toLowerCase());
}
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
raw += chunk.substring(0, MAX_STDIN - raw.length);
}
});
process.stdin.on('end', () => {
if (!isEnabled(process.env.ECC_ENABLE_INSAITS)) {
process.stdout.write(raw);
process.exit(0);
}
const scriptDir = __dirname;
const pyScript = path.join(scriptDir, 'insaits-security-monitor.py');
// Prefer real Windows executables before .cmd shims so shell execution is
// only used for wrapper scripts such as pyenv/npm-style shims.
const pythonCandidates = process.platform === 'win32'
? ['python3.exe', 'python.exe', 'python3.cmd', 'python.cmd', 'python3', 'python']
: ['python3', 'python'];
let result;
for (const pythonBin of pythonCandidates) {
const useWindowsShell = process.platform === 'win32' && /\.(cmd|bat)$/i.test(pythonBin);
if (useWindowsShell && (
WINDOWS_SHELL_UNSAFE_PATH_CHARS.test(pythonBin)
|| WINDOWS_SHELL_UNSAFE_PATH_CHARS.test(pyScript)
)) {
result = {
error: new Error(`Unsafe Windows Python shim path: ${pythonBin}`),
};
break;
}
result = spawnSync(pythonBin, [pyScript], {
input: raw,
encoding: 'utf8',
env: process.env,
cwd: process.cwd(),
timeout: 14000,
shell: useWindowsShell,
windowsHide: true,
});
// ENOENT means binary not found - try next candidate
if (result.error && result.error.code === 'ENOENT') {
continue;
}
break;
}
if (!result || (result.error && result.error.code === 'ENOENT')) {
process.stderr.write('[InsAIts] python3/python not found. Install Python 3.9+ and: pip install insa-its\n');
process.stdout.write(raw);
process.exit(0);
}
// Log non-ENOENT spawn errors (timeout, signal kill, etc.) so users
// know the security monitor did not run - fail-open with a warning.
if (result.error) {
process.stderr.write(`[InsAIts] Security monitor failed to run: ${result.error.message}\n`);
process.stdout.write(raw);
process.exit(0);
}
// result.status is null when the process was killed by a signal or
// timed out. Check BEFORE writing stdout to avoid leaking partial
// or corrupt monitor output. Pass through original raw input instead.
if (!Number.isInteger(result.status)) {
const signal = result.signal || 'unknown';
process.stderr.write(`[InsAIts] Security monitor killed (signal: ${signal}). Tool execution continues.\n`);
process.stdout.write(raw);
process.exit(0);
}
// The monitor only uses 0 (pass) and 2 (block). Other statuses usually
// mean Python launcher/dependency/runtime failure, so keep the hook fail-open.
if (result.status !== 0 && result.status !== 2) {
const detail = (result.stderr || result.stdout || '').trim();
const suffix = detail ? `: ${detail}` : '';
process.stderr.write(`[InsAIts] Security monitor exited with status ${result.status}${suffix}\n`);
process.stdout.write(raw);
process.exit(0);
}
if (result.stdout) {
process.stdout.write(result.stdout);
} else if (result.status === 0) {
process.stdout.write(raw);
}
if (result.stderr) process.stderr.write(result.stderr);
process.exit(result.status);
});
+749
View File
@@ -0,0 +1,749 @@
#!/usr/bin/env node
'use strict';
/**
* MCP health-check hook.
*
* Compatible with Claude Code's existing hook events:
* - PreToolUse: probe MCP server health before MCP tool execution
* - PostToolUseFailure: mark unhealthy servers, attempt reconnect, and re-probe
*
* The hook persists health state outside the conversation context so it
* survives compaction and later turns.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const http = require('http');
const https = require('https');
const { spawn, spawnSync } = require('child_process');
const MAX_STDIN = 1024 * 1024;
const DEFAULT_TTL_MS = 2 * 60 * 1000;
const DEFAULT_TIMEOUT_MS = 5000;
const DEFAULT_BACKOFF_MS = 30 * 1000;
const MAX_BACKOFF_MS = 10 * 60 * 1000;
// The preflight HTTP probe only checks reachability; it does not have access to
// Claude Code's stored OAuth bearer token. Treat auth-gated responses as
// reachable so the real MCP client can attempt the authenticated call. A
// Streamable HTTP MCP server can also return 406 to a bare GET that omits
// Accept: text/event-stream; that still proves the endpoint is alive.
const HEALTHY_HTTP_CODES = new Set([200, 201, 202, 204, 301, 302, 303, 304, 307, 308, 400, 401, 403, 405, 406]);
const RECONNECT_STATUS_CODES = new Set([401, 403, 429, 503]);
const FAILURE_PATTERNS = [
{ code: 401, pattern: /\b401\b|unauthori[sz]ed|auth(?:entication)?\s+(?:failed|expired|invalid)/i },
{ code: 403, pattern: /\b403\b|forbidden|permission denied/i },
{ code: 429, pattern: /\b429\b|rate limit|too many requests/i },
{ code: 503, pattern: /\b503\b|service unavailable|overloaded|temporarily unavailable/i },
{ code: 'transport', pattern: /ECONNREFUSED|ENOTFOUND|EAI_AGAIN|timed? out|socket hang up|connection (?:failed|lost|reset|closed)/i }
];
function envNumber(name, fallback) {
const value = Number(process.env[name]);
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
function stateFilePath() {
if (process.env.ECC_MCP_HEALTH_STATE_PATH) {
return path.resolve(process.env.ECC_MCP_HEALTH_STATE_PATH);
}
return path.join(os.homedir(), '.claude', 'mcp-health-cache.json');
}
function configPaths() {
if (process.env.ECC_MCP_CONFIG_PATH) {
return process.env.ECC_MCP_CONFIG_PATH
.split(path.delimiter)
.map(entry => entry.trim())
.filter(Boolean)
.map(entry => path.resolve(entry));
}
const cwd = process.cwd();
const home = os.homedir();
return [
path.join(cwd, '.claude.json'),
path.join(cwd, '.claude', 'settings.json'),
path.join(home, '.claude.json'),
path.join(home, '.claude', 'settings.json')
];
}
function readJsonFile(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch {
return null;
}
}
function loadState(filePath) {
const state = readJsonFile(filePath);
if (!state || typeof state !== 'object' || Array.isArray(state)) {
return { version: 1, servers: {} };
}
if (!state.servers || typeof state.servers !== 'object' || Array.isArray(state.servers)) {
state.servers = {};
}
return state;
}
function saveState(filePath, state) {
try {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(state, null, 2));
} catch {
// Never block the hook on state persistence errors.
}
}
function readRawStdin() {
return new Promise(resolve => {
let raw = '';
let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || ''));
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
if (chunk.length > remaining) {
truncated = true;
}
} else {
truncated = true;
}
});
process.stdin.on('end', () => resolve({ raw, truncated }));
process.stdin.on('error', () => resolve({ raw, truncated }));
});
}
function safeParse(raw) {
try {
return raw.trim() ? JSON.parse(raw) : {};
} catch {
return {};
}
}
function extractMcpTarget(input) {
const toolName = String(input.tool_name || input.name || '');
const explicitServer = input.server
|| input.mcp_server
|| input.tool_input?.server
|| input.tool_input?.mcp_server
|| input.tool_input?.connector
|| null;
const explicitTool = input.tool
|| input.mcp_tool
|| input.tool_input?.tool
|| input.tool_input?.mcp_tool
|| null;
if (explicitServer) {
return {
server: String(explicitServer),
tool: explicitTool ? String(explicitTool) : toolName
};
}
if (!toolName.startsWith('mcp__')) {
return null;
}
const segments = toolName.slice(5).split('__');
if (segments.length < 2 || !segments[0]) {
return null;
}
return {
server: segments[0],
tool: segments.slice(1).join('__')
};
}
function extractMcpTargetFromRaw(raw) {
const toolNameMatch = raw.match(/"(?:tool_name|name)"\s*:\s*"([^"]+)"/);
const serverMatch = raw.match(/"(?:server|mcp_server|connector)"\s*:\s*"([^"]+)"/);
const toolMatch = raw.match(/"(?:tool|mcp_tool)"\s*:\s*"([^"]+)"/);
return extractMcpTarget({
tool_name: toolNameMatch ? toolNameMatch[1] : '',
server: serverMatch ? serverMatch[1] : undefined,
tool: toolMatch ? toolMatch[1] : undefined
});
}
function resolveServerConfig(serverName) {
for (const filePath of configPaths()) {
const data = readJsonFile(filePath);
const server = data?.mcpServers?.[serverName]
|| data?.mcp_servers?.[serverName]
|| null;
if (server && typeof server === 'object' && !Array.isArray(server)) {
return {
config: server,
source: filePath
};
}
}
return null;
}
function markHealthy(state, serverName, now, details = {}) {
state.servers[serverName] = {
status: 'healthy',
checkedAt: now,
expiresAt: now + envNumber('ECC_MCP_HEALTH_TTL_MS', DEFAULT_TTL_MS),
failureCount: 0,
lastError: null,
lastFailureCode: null,
nextRetryAt: now,
lastRestoredAt: now,
...details
};
}
function markUnhealthy(state, serverName, now, failureCode, errorMessage) {
const previous = state.servers[serverName] || {};
const failureCount = Number(previous.failureCount || 0) + 1;
const backoffBase = envNumber('ECC_MCP_HEALTH_BACKOFF_MS', DEFAULT_BACKOFF_MS);
const nextRetryDelay = Math.min(backoffBase * (2 ** Math.max(failureCount - 1, 0)), MAX_BACKOFF_MS);
state.servers[serverName] = {
status: 'unhealthy',
checkedAt: now,
expiresAt: now,
failureCount,
lastError: errorMessage || null,
lastFailureCode: failureCode || null,
nextRetryAt: now + nextRetryDelay,
lastRestoredAt: previous.lastRestoredAt || null
};
}
function failureSummary(input) {
const output = input.tool_output;
const pieces = [
typeof input.error === 'string' ? input.error : '',
typeof input.message === 'string' ? input.message : '',
typeof input.tool_response === 'string' ? input.tool_response : '',
typeof output === 'string' ? output : '',
typeof output?.output === 'string' ? output.output : '',
typeof output?.stderr === 'string' ? output.stderr : '',
typeof input.tool_input?.error === 'string' ? input.tool_input.error : ''
].filter(Boolean);
return pieces.join('\n');
}
function detectFailureCode(text) {
const summary = String(text || '');
for (const entry of FAILURE_PATTERNS) {
if (entry.pattern.test(summary)) {
return entry.code;
}
}
return null;
}
function requestHttp(urlString, headers, timeoutMs) {
return new Promise(resolve => {
let settled = false;
let timedOut = false;
const url = new URL(urlString);
const client = url.protocol === 'https:' ? https : http;
const req = client.request(
url,
{
method: 'GET',
headers,
},
res => {
if (settled) return;
settled = true;
res.resume();
resolve({
ok: HEALTHY_HTTP_CODES.has(res.statusCode),
statusCode: res.statusCode,
reason: `HTTP ${res.statusCode}`
});
}
);
req.setTimeout(timeoutMs, () => {
timedOut = true;
req.destroy(new Error('timeout'));
});
req.on('error', error => {
if (settled) return;
settled = true;
resolve({
ok: false,
statusCode: null,
reason: timedOut ? 'request timed out' : error.message
});
});
req.end();
});
}
function probeCommandServer(serverName, config) {
return new Promise(resolve => {
const command = config.command;
const args = Array.isArray(config.args) ? config.args.map(arg => String(arg)) : [];
const timeoutMs = envNumber('ECC_MCP_HEALTH_TIMEOUT_MS', DEFAULT_TIMEOUT_MS);
const mergedEnv = {
...process.env,
...(config.env && typeof config.env === 'object' && !Array.isArray(config.env) ? config.env : {})
};
let done = false;
function finish(result) {
if (done) return;
done = true;
resolve(result);
}
// On Windows, commands like 'npx' are commonly exposed as npx.cmd.
// Probe bare PATH commands through platform-extension fallbacks, but keep
// absolute/relative path commands as a single candidate so their existing
// ENOENT failure semantics stay intact.
const commandIsString = typeof command === 'string' && command.length > 0;
const isPathLike = commandIsString && (
path.isAbsolute(command)
|| command.includes('/')
|| command.includes('\\')
);
const candidates = process.platform === 'win32'
&& commandIsString
&& !path.extname(command)
&& !isPathLike
? [command, `${command}.cmd`, `${command}.exe`, `${command}.bat`]
: [command];
// cmd.exe treats these as operators, grouping syntax, expansion markers,
// separators, or argument boundaries. Do not route such command strings
// through shell mode.
const UNSAFE_SHELL_CHARS = /[&|<>^%!()\s;]/;
// When spawning via cmd.exe (shell:true) on Windows, Node concatenates
// command + args WITHOUT quoting (DEP0190). An arg containing a space —
// such as a path under "C:\Program Files" — gets re-split by cmd.exe.
// Build a properly-quoted command line instead and pass it as a single
// string with no args array, so cmd.exe sees each token as one unit.
function quoteWin(token) {
// If the token has no characters that need quoting, return it as-is.
if (!/[\s"&|<>^%!();]/.test(token)) {
return token;
}
// Escape embedded double quotes by doubling them, then wrap in double
// quotes. cmd.exe uses "" as an escaped quote inside a quoted string.
return '"' + token.replace(/"/g, '""') + '"';
}
function attempt(idx) {
const tryCommand = candidates[idx];
const isLast = idx + 1 >= candidates.length;
let stderr = '';
let attemptDone = false;
let timer = null;
function retryNext() {
if (attemptDone) return;
attemptDone = true;
if (timer) {
clearTimeout(timer);
timer = null;
}
attempt(idx + 1);
}
function attemptFinish(result) {
if (attemptDone) return;
attemptDone = true;
if (timer) {
clearTimeout(timer);
timer = null;
}
finish(result);
}
// Node 18.20+/20.12+ refuse to spawn .cmd/.bat directly on Windows
// after the CVE-2024-27980 mitigation. Only those extension candidates
// go through cmd.exe, after the command string is shell-character clean.
const useShell = process.platform === 'win32'
&& typeof tryCommand === 'string'
&& /\.(cmd|bat)$/i.test(tryCommand)
&& !UNSAFE_SHELL_CHARS.test(tryCommand);
let child;
try {
if (useShell) {
// Build a single quoted command line for cmd.exe. Passing an args
// array with shell:true causes Node to concatenate without quoting
// (DEP0190), which splits space-containing args (e.g. paths under
// "C:\Program Files") at every space boundary.
const quotedCmdline = [tryCommand, ...args].map(quoteWin).join(' ');
child = spawn(quotedCmdline, {
env: mergedEnv,
cwd: process.cwd(),
stdio: ['pipe', 'ignore', 'pipe'],
shell: true
});
} else {
child = spawn(tryCommand, args, {
env: mergedEnv,
cwd: process.cwd(),
stdio: ['pipe', 'ignore', 'pipe'],
shell: false
});
}
} catch (error) {
if ((error.code === 'ENOENT' || error.code === 'EINVAL') && !isLast) {
retryNext();
return;
}
attemptFinish({
ok: false,
statusCode: null,
reason: error.message
});
return;
}
child.stderr.on('data', chunk => {
if (stderr.length < 4000) {
const remaining = 4000 - stderr.length;
stderr += String(chunk).slice(0, remaining);
}
});
child.on('error', error => {
if ((error.code === 'ENOENT' || error.code === 'EINVAL') && !isLast) {
retryNext();
return;
}
attemptFinish({
ok: false,
statusCode: null,
reason: error.message
});
});
child.on('exit', (code, signal) => {
attemptFinish({
ok: false,
statusCode: code,
reason: stderr.trim() || `process exited before handshake (${signal || code || 'unknown'})`
});
});
timer = setTimeout(() => {
// A fast-crashing stdio server can finish before the timer callback runs
// on a loaded machine. Check the process state again before classifying it
// as healthy on timeout.
if (child.exitCode !== null || child.signalCode !== null) {
attemptFinish({
ok: false,
statusCode: child.exitCode,
reason: stderr.trim() || `process exited before handshake (${child.signalCode || child.exitCode || 'unknown'})`
});
return;
}
try {
if (useShell && child.pid && process.platform === 'win32') {
// When spawned via shell on Windows, child is cmd.exe. kill() only
// terminates the shell and leaves the real server process orphaned.
// taskkill /T kills the entire process tree rooted at cmd.exe.
const killResult = spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
stdio: 'ignore',
windowsHide: true
});
if (killResult.error || (typeof killResult.status === 'number' && killResult.status !== 0)) {
// taskkill not on PATH, permission denied, or already exited.
// Best-effort fallback: signal the cmd.exe shell directly. The
// child tree may still leak if it already detached, but this at
// least kills the shell we spawned.
try { child.kill('SIGKILL'); } catch { /* ignore */ }
}
} else {
child.kill('SIGTERM');
setTimeout(() => {
try {
child.kill('SIGKILL');
} catch {
// ignore
}
}, 200).unref?.();
}
} catch {
// ignore
}
attemptFinish({
ok: true,
statusCode: null,
reason: `${serverName} accepted a new stdio process`
});
}, timeoutMs);
if (typeof timer.unref === 'function') {
timer.unref();
}
}
attempt(0);
});
}
async function probeServer(serverName, resolvedConfig) {
const config = resolvedConfig.config;
if (config.type === 'http' || config.url) {
const result = await requestHttp(config.url, config.headers || {}, envNumber('ECC_MCP_HEALTH_TIMEOUT_MS', DEFAULT_TIMEOUT_MS));
return {
ok: result.ok,
failureCode: RECONNECT_STATUS_CODES.has(result.statusCode) ? result.statusCode : null,
reason: result.reason,
source: resolvedConfig.source
};
}
if (config.command) {
const result = await probeCommandServer(serverName, config);
return {
ok: result.ok,
failureCode: RECONNECT_STATUS_CODES.has(result.statusCode) ? result.statusCode : null,
reason: result.reason,
source: resolvedConfig.source
};
}
return {
ok: false,
failureCode: null,
reason: 'unsupported MCP server config',
source: resolvedConfig.source
};
}
function reconnectCommand(serverName) {
const key = `ECC_MCP_RECONNECT_${String(serverName).toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
const command = process.env[key] || process.env.ECC_MCP_RECONNECT_COMMAND || '';
if (!command.trim()) {
return null;
}
return command.includes('{server}')
? command.replace(/\{server\}/g, serverName)
: command;
}
function attemptReconnect(serverName) {
const command = reconnectCommand(serverName);
if (!command) {
return { attempted: false, success: false, reason: 'no reconnect command configured' };
}
const result = spawnSync(command, {
shell: true,
env: process.env,
cwd: process.cwd(),
encoding: 'utf8',
timeout: envNumber('ECC_MCP_RECONNECT_TIMEOUT_MS', DEFAULT_TIMEOUT_MS)
});
if (result.error) {
return { attempted: true, success: false, reason: result.error.message };
}
if (result.status !== 0) {
return {
attempted: true,
success: false,
reason: (result.stderr || result.stdout || `reconnect exited ${result.status}`).trim()
};
}
return { attempted: true, success: true, reason: 'reconnect command completed' };
}
function shouldFailOpen() {
return /^(1|true|yes)$/i.test(String(process.env.ECC_MCP_HEALTH_FAIL_OPEN || ''));
}
function emitLogs(logs) {
for (const line of logs) {
process.stderr.write(`${line}\n`);
}
}
async function handlePreToolUse(rawInput, input, target, statePathValue, now) {
const logs = [];
const state = loadState(statePathValue);
const previous = state.servers[target.server] || {};
if (previous.status === 'healthy' && Number(previous.expiresAt || 0) > now) {
return { rawInput, exitCode: 0, logs };
}
if (previous.status === 'unhealthy' && Number(previous.nextRetryAt || 0) > now) {
logs.push(
`[MCPHealthCheck] ${target.server} is marked unhealthy until ${new Date(previous.nextRetryAt).toISOString()}; skipping ${target.tool || 'tool'}`
);
return { rawInput, exitCode: shouldFailOpen() ? 0 : 2, logs };
}
const resolvedConfig = resolveServerConfig(target.server);
if (!resolvedConfig) {
logs.push(`[MCPHealthCheck] No MCP config found for ${target.server}; skipping preflight probe`);
return { rawInput, exitCode: 0, logs };
}
const probe = await probeServer(target.server, resolvedConfig);
if (probe.ok) {
markHealthy(state, target.server, now, { source: resolvedConfig.source });
saveState(statePathValue, state);
if (previous.status === 'unhealthy') {
logs.push(`[MCPHealthCheck] ${target.server} connection restored`);
}
return { rawInput, exitCode: 0, logs };
}
let reconnect = { attempted: false, success: false, reason: 'probe failed' };
if (probe.failureCode || previous.status === 'unhealthy') {
reconnect = attemptReconnect(target.server);
if (reconnect.success) {
const reprobe = await probeServer(target.server, resolvedConfig);
if (reprobe.ok) {
markHealthy(state, target.server, now, {
source: resolvedConfig.source,
restoredBy: 'reconnect-command'
});
saveState(statePathValue, state);
logs.push(`[MCPHealthCheck] ${target.server} connection restored after reconnect`);
return { rawInput, exitCode: 0, logs };
}
probe.reason = `${probe.reason}; reconnect reprobe failed: ${reprobe.reason}`;
}
}
markUnhealthy(state, target.server, now, probe.failureCode, probe.reason);
saveState(statePathValue, state);
const reconnectSuffix = reconnect.attempted
? ` Reconnect attempt: ${reconnect.success ? 'ok' : reconnect.reason}.`
: '';
logs.push(
`[MCPHealthCheck] ${target.server} is unavailable (${probe.reason}). Blocking ${target.tool || 'tool'} so Claude can fall back to non-MCP tools.${reconnectSuffix}`
);
return { rawInput, exitCode: shouldFailOpen() ? 0 : 2, logs };
}
async function handlePostToolUseFailure(rawInput, input, target, statePathValue, now) {
const logs = [];
const summary = failureSummary(input);
const failureCode = detectFailureCode(summary);
if (!failureCode) {
return { rawInput, exitCode: 0, logs };
}
const state = loadState(statePathValue);
markUnhealthy(state, target.server, now, failureCode, summary.slice(0, 500));
saveState(statePathValue, state);
logs.push(`[MCPHealthCheck] ${target.server} reported ${failureCode}; marking server unhealthy and attempting reconnect`);
const reconnect = attemptReconnect(target.server);
if (!reconnect.attempted) {
logs.push(`[MCPHealthCheck] ${target.server} reconnect skipped: ${reconnect.reason}`);
return { rawInput, exitCode: 0, logs };
}
if (!reconnect.success) {
logs.push(`[MCPHealthCheck] ${target.server} reconnect failed: ${reconnect.reason}`);
return { rawInput, exitCode: 0, logs };
}
const resolvedConfig = resolveServerConfig(target.server);
if (!resolvedConfig) {
logs.push(`[MCPHealthCheck] ${target.server} reconnect completed but no config was available for a follow-up probe`);
return { rawInput, exitCode: 0, logs };
}
const reprobe = await probeServer(target.server, resolvedConfig);
if (!reprobe.ok) {
logs.push(`[MCPHealthCheck] ${target.server} reconnect command ran, but health probe still failed: ${reprobe.reason}`);
return { rawInput, exitCode: 0, logs };
}
const refreshed = loadState(statePathValue);
markHealthy(refreshed, target.server, now, {
source: resolvedConfig.source,
restoredBy: 'post-failure-reconnect'
});
saveState(statePathValue, refreshed);
logs.push(`[MCPHealthCheck] ${target.server} connection restored`);
return { rawInput, exitCode: 0, logs };
}
async function main() {
const { raw: rawInput, truncated } = await readRawStdin();
const input = safeParse(rawInput);
const target = extractMcpTarget(input) || (truncated ? extractMcpTargetFromRaw(rawInput) : null);
if (!target) {
process.stdout.write(rawInput);
process.exit(0);
return;
}
if (truncated) {
const limit = Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN;
const logs = [
shouldFailOpen()
? `[MCPHealthCheck] Hook input exceeded ${limit} bytes while checking ${target.server}; allowing ${target.tool || 'tool'} because fail-open mode is enabled`
: `[MCPHealthCheck] Hook input exceeded ${limit} bytes while checking ${target.server}; blocking ${target.tool || 'tool'} to avoid bypassing MCP health checks`
];
emitLogs(logs);
process.stdout.write(rawInput);
process.exit(shouldFailOpen() ? 0 : 2);
return;
}
const eventName = process.env.CLAUDE_HOOK_EVENT_NAME || 'PreToolUse';
const now = Date.now();
const statePathValue = stateFilePath();
const result = eventName === 'PostToolUseFailure'
? await handlePostToolUseFailure(rawInput, input, target, statePathValue, now)
: await handlePreToolUse(rawInput, input, target, statePathValue, now);
emitLogs(result.logs);
process.stdout.write(result.rawInput);
process.exit(result.exitCode);
}
main().catch(error => {
process.stderr.write(`[MCPHealthCheck] Unexpected error: ${error.message}\n`);
process.exit(0);
});
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const OBSERVE_RELATIVE_PATH = path.join('skills', 'continuous-learning-v2', 'hooks', 'observe.sh');
const DEFAULT_TIMEOUT_MS = 9000;
function getPluginRoot(options = {}) {
if (options.pluginRoot && String(options.pluginRoot).trim()) {
return String(options.pluginRoot).trim();
}
if (process.env.CLAUDE_PLUGIN_ROOT && process.env.CLAUDE_PLUGIN_ROOT.trim()) {
return process.env.CLAUDE_PLUGIN_ROOT.trim();
}
if (process.env.ECC_PLUGIN_ROOT && process.env.ECC_PLUGIN_ROOT.trim()) {
return process.env.ECC_PLUGIN_ROOT.trim();
}
return path.resolve(__dirname, '..', '..');
}
function resolveTarget(rootDir, relPath) {
const resolvedRoot = path.resolve(rootDir);
const resolvedTarget = path.resolve(rootDir, relPath);
if (
resolvedTarget !== resolvedRoot &&
!resolvedTarget.startsWith(resolvedRoot + path.sep)
) {
throw new Error(`Path traversal rejected: ${relPath}`);
}
return resolvedTarget;
}
function toShellPath(filePath) {
const normalized = String(filePath || '');
if (process.platform !== 'win32') {
return normalized;
}
return normalized
.replace(/^([A-Za-z]):[\\/]/, (_, driveLetter) => `/${driveLetter.toLowerCase()}/`)
.replace(/\\/g, '/');
}
function findShellBinary() {
const candidates = [];
if (process.env.BASH && process.env.BASH.trim()) {
candidates.push(process.env.BASH.trim());
}
if (process.platform === 'win32') {
candidates.push('bash.exe', 'bash', 'sh');
} else {
candidates.push('bash', 'sh');
}
for (const candidate of candidates) {
const probe = spawnSync(candidate, ['-c', ':'], {
stdio: 'ignore',
windowsHide: true
});
if (!probe.error) {
return candidate;
}
}
return null;
}
function getPhaseFromHookId(hookId) {
const prefix = String(hookId || process.env.ECC_HOOK_ID || '').split(':')[0];
return prefix === 'pre' || prefix === 'post' ? prefix : null;
}
function getTimeoutMs() {
const parsed = Number.parseInt(process.env.ECC_OBSERVE_RUNNER_TIMEOUT_MS || '', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
}
function combineStderr(stderr, message) {
const prefix = typeof stderr === 'string' && stderr.length > 0
? stderr.endsWith('\n') ? stderr : `${stderr}\n`
: '';
return `${prefix}${message}\n`;
}
function run(raw, options = {}) {
const input = typeof raw === 'string' ? raw : String(raw ?? '');
const phase = getPhaseFromHookId(options.hookId);
if (!phase) {
return {
stderr: '[Hook] observe runner received an unsupported hook id; skipping observation',
exitCode: 0
};
}
const pluginRoot = getPluginRoot(options);
let observePath;
try {
observePath = resolveTarget(pluginRoot, OBSERVE_RELATIVE_PATH);
} catch (error) {
return {
stderr: `[Hook] observe runner path resolution failed: ${error.message}`,
exitCode: 0
};
}
if (!fs.existsSync(observePath)) {
return {
stderr: `[Hook] observe script not found: ${observePath}`,
exitCode: 0
};
}
const shell = findShellBinary();
if (!shell) {
return {
stderr: '[Hook] shell runtime unavailable; skipping continuous-learning observation',
exitCode: 0
};
}
const result = spawnSync(shell, [toShellPath(observePath), phase], {
input,
encoding: 'utf8',
env: {
...process.env,
CLAUDE_PLUGIN_ROOT: pluginRoot,
ECC_PLUGIN_ROOT: pluginRoot
},
cwd: process.cwd(),
timeout: getTimeoutMs(),
windowsHide: true
});
const output = {
exitCode: Number.isInteger(result.status) ? result.status : 0
};
if (typeof result.stdout === 'string' && result.stdout.length > 0) {
output.stdout = result.stdout;
}
if (typeof result.stderr === 'string' && result.stderr.length > 0) {
output.stderr = result.stderr;
}
if (result.error || result.signal || result.status === null) {
const reason = result.error
? result.error.message
: result.signal
? `terminated by signal ${result.signal}`
: 'missing exit status';
output.stderr = combineStderr(output.stderr, `[Hook] observe runner failed: ${reason}`);
output.exitCode = 0;
}
return output;
}
function emitHookResult(raw, output) {
if (output && typeof output === 'object') {
if (output.stderr) {
process.stderr.write(String(output.stderr).endsWith('\n') ? String(output.stderr) : `${output.stderr}\n`);
}
if (Object.prototype.hasOwnProperty.call(output, 'stdout')) {
process.stdout.write(String(output.stdout ?? ''));
} else if (!Number.isInteger(output.exitCode) || output.exitCode === 0) {
process.stdout.write(raw);
}
return Number.isInteger(output.exitCode) ? output.exitCode : 0;
}
process.stdout.write(raw);
return 0;
}
if (require.main === module) {
let raw = '';
try {
raw = fs.readFileSync(0, 'utf8');
} catch (_error) {
raw = '';
}
const output = run(raw, { hookId: process.argv[2] || process.env.ECC_HOOK_ID });
process.exit(emitHookResult(raw, output));
}
module.exports = {
OBSERVE_RELATIVE_PATH,
findShellBinary,
getPhaseFromHookId,
run,
toShellPath
};
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env node
/**
* Plan Canvas open-session surfacing (SessionStart)
*
* Cross-platform (Windows, macOS, Linux)
*
* If a Plan Canvas review is still open from a previous agent session,
* surface it at session start so a fresh session can resume the loop with
* `plan-canvas await <file>` instead of leaving the human talking to an
* empty chair in the browser.
*
* Never blocks: exits 0 on every error, prints nothing when there is
* nothing to resume.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
function stateDir() {
const override = process.env.ECC_PLAN_CANVAS_STATE_DIR;
if (override && override.trim()) return path.resolve(override.trim());
return path.join(os.homedir(), '.claude', 'plan-canvas');
}
function openSessions() {
try {
const parsed = JSON.parse(fs.readFileSync(path.join(stateDir(), 'sessions.json'), 'utf8'));
return Object.values(parsed.sessions || {}).filter(session => session.status !== 'ended');
} catch {
return [];
}
}
function buildContext(sessions) {
const lines = [
'[PlanCanvas] Open browser review sessions from a previous run:'
];
for (const session of sessions.slice(0, 5)) {
const pending = session.pendingFeedback && session.pendingFeedback.length;
lines.push(` - ${session.file}${pending ? ` (${pending} undelivered feedback item${pending === 1 ? '' : 's'})` : ''}`);
}
lines.push(
'Resume with `node scripts/plan-canvas.js await <file>` (plan-canvas skill), or `end <file>` if the review is obsolete.'
);
return lines.join('\n');
}
function run() {
const sessions = openSessions();
if (sessions.length > 0) {
process.stdout.write(`${buildContext(sessions)}\n`);
}
return 0;
}
if (require.main === module) {
try {
process.exit(run());
} catch (error) {
process.stderr.write(`[PlanCanvas] WARNING: ${error.message}\n`);
process.exit(0);
}
}
module.exports = { run, openSessions, buildContext };
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { ensureAgentDataHomeEnv } = require('../lib/agent-data-home');
function readStdinRaw() {
try {
return fs.readFileSync(0, 'utf8');
} catch (_error) {
return '';
}
}
function writeStderr(stderr) {
if (typeof stderr === 'string' && stderr.length > 0) {
process.stderr.write(stderr);
}
}
function passthrough(raw, result) {
const stdout = typeof result?.stdout === 'string' ? result.stdout : '';
if (stdout) {
process.stdout.write(stdout);
return;
}
if (!Number.isInteger(result?.status) || result.status === 0) {
process.stdout.write(raw);
}
}
function normalizePluginRootForPlatform(rootDir, platform = process.platform) {
if (platform !== 'win32' || typeof rootDir !== 'string') {
return rootDir;
}
const match = rootDir.match(/^\/([a-zA-Z])(?:\/(.*))?$/);
if (!match) {
return rootDir;
}
const [, driveLetter, rest = ''] = match;
return `${driveLetter.toUpperCase()}:/${rest}`;
}
function resolveTarget(rootDir, relPath) {
const resolvedRoot = path.resolve(rootDir);
const resolvedTarget = path.resolve(rootDir, relPath);
if (
resolvedTarget !== resolvedRoot &&
!resolvedTarget.startsWith(resolvedRoot + path.sep)
) {
throw new Error(`Path traversal rejected: ${relPath}`);
}
return resolvedTarget;
}
let _cachedShell = undefined;
let _cachedBash = undefined;
function isPowerShellBin(bin) {
const base = path.basename(bin).toLowerCase();
return base === 'pwsh.exe' || base === 'pwsh' || base === 'powershell.exe' || base === 'powershell';
}
function findShellBinary() {
if (_cachedShell !== undefined) return _cachedShell;
const candidates = [];
// Explicit override always wins — check before any platform probing.
// Warning: setting BASH to a bash binary on Windows bypasses the PowerShell
// preference and may reintroduce bash.exe zombie accumulation.
if (process.env.BASH && process.env.BASH.trim()) {
candidates.push(process.env.BASH.trim());
}
if (process.platform === 'win32') {
// Prefer PowerShell on Windows — it is native and does not leave zombie
// bash.exe / conhost.exe processes the way MSYS2/Git Bash does.
// Note: PowerShell is only suitable for .ps1 scripts; callers that need
// to run .sh scripts (e.g. observe-runner.js) must not use this function.
candidates.push('pwsh.exe', 'powershell.exe', 'bash.exe', 'bash');
} else {
candidates.push('bash', 'sh');
}
const psProbeArgs = ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'];
const shProbeArgs = ['-c', ':'];
for (const candidate of candidates) {
const probe = spawnSync(candidate, isPowerShellBin(candidate) ? psProbeArgs : shProbeArgs, {
stdio: 'ignore',
windowsHide: true,
timeout: 30000,
});
// A candidate is only usable if it both spawns AND exits cleanly. The
// Windows System32 bash.exe WSL launcher spawns without error but exits
// non-zero when no distro is installed, so !probe.error alone is not enough.
if (!probe.error && probe.status === 0) {
_cachedShell = candidate;
return _cachedShell;
}
}
_cachedShell = null;
return null;
}
function findBashBinary() {
if (_cachedBash !== undefined) return _cachedBash;
const candidates = [];
if (process.env.BASH && process.env.BASH.trim() && !isPowerShellBin(process.env.BASH.trim())) {
candidates.push(process.env.BASH.trim());
}
candidates.push('bash.exe', 'bash');
for (const candidate of candidates) {
const probe = spawnSync(candidate, ['-c', ':'], { stdio: 'ignore', windowsHide: true, timeout: 30000 });
// Require a clean exit, not just a successful spawn: the Windows System32
// bash.exe WSL stub spawns fine but exits non-zero with no distro installed.
if (!probe.error && probe.status === 0) {
_cachedBash = candidate;
return _cachedBash;
}
}
_cachedBash = null;
return null;
}
function spawnNode(rootDir, relPath, raw, args) {
ensureAgentDataHomeEnv();
const hookEnv = {
...process.env,
CLAUDE_PLUGIN_ROOT: rootDir,
ECC_PLUGIN_ROOT: rootDir,
};
return spawnSync(process.execPath, [resolveTarget(rootDir, relPath), ...args], {
input: raw,
encoding: 'utf8',
env: hookEnv,
cwd: process.cwd(),
timeout: 30000,
windowsHide: true,
});
}
// spawnShell is not used by any hook in the shipped hooks.json configuration
// (all hooks use 'node' mode). It is provided for third-party plugins that
// register shell-backed hooks. Plugins should supply .ps1 scripts on Windows
// and .sh scripts on Unix; mixing them will produce a skip with a stderr warning.
function spawnShell(rootDir, relPath, raw, args) {
const shell = findShellBinary();
if (!shell) {
return {
status: 0,
stdout: '',
stderr: '[Hook] shell runtime unavailable; skipping shell-backed hook\n',
};
}
ensureAgentDataHomeEnv();
const hookEnv = {
...process.env,
CLAUDE_PLUGIN_ROOT: rootDir,
ECC_PLUGIN_ROOT: rootDir,
};
const scriptPath = resolveTarget(rootDir, relPath);
const isPs = isPowerShellBin(shell);
// PowerShell cannot interpret bash scripts — fall back to a bash candidate
// rather than silently failing the hook.
if (isPs && scriptPath.endsWith('.sh')) {
const bash = findBashBinary();
if (!bash) {
return {
status: 0,
stdout: '',
stderr: '[Hook] .sh script requested but no bash binary found on Windows; skipping\n',
};
}
return spawnSync(bash, [scriptPath, ...args], {
input: raw,
encoding: 'utf8',
env: hookEnv,
cwd: process.cwd(),
timeout: 30000,
windowsHide: true,
});
}
const shellArgs = isPs
// -ExecutionPolicy Bypass: default Windows policy (Restricted) blocks -File
// execution of .ps1 scripts; Bypass scopes only to this child process.
? ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
: [scriptPath, ...args];
return spawnSync(shell, shellArgs, {
input: raw,
encoding: 'utf8',
env: hookEnv,
cwd: process.cwd(),
timeout: 30000,
windowsHide: true,
});
}
function main() {
const [, , mode, relPath, ...args] = process.argv;
const raw = readStdinRaw();
const rootDir = normalizePluginRootForPlatform(
process.env.CLAUDE_PLUGIN_ROOT || process.env.ECC_PLUGIN_ROOT
);
if (!mode || !relPath || !rootDir) {
process.stdout.write(raw);
process.exit(0);
}
let result;
try {
if (mode === 'node') {
result = spawnNode(rootDir, relPath, raw, args);
} else if (mode === 'shell') {
result = spawnShell(rootDir, relPath, raw, args);
} else {
writeStderr(`[Hook] unknown bootstrap mode: ${mode}\n`);
process.stdout.write(raw);
process.exit(0);
}
} catch (error) {
writeStderr(`[Hook] bootstrap resolution failed: ${error.message}\n`);
process.stdout.write(raw);
process.exit(0);
}
passthrough(raw, result);
writeStderr(result.stderr);
if (result.error || result.signal || result.status === null) {
const reason = result.error
? result.error.message
: result.signal
? `terminated by signal ${result.signal}`
: 'missing exit status';
writeStderr(`[Hook] bootstrap execution failed: ${reason}\n`);
process.exit(0);
}
process.exit(Number.isInteger(result.status) ? result.status : 0);
}
// Run when invoked as a hook entry. Production hooks load this via
// `node -e "...; process.argv.splice(1,0,s); require(s)"`; on Node 21+ that
// leaves require.main undefined (not this module), which previously skipped
// main() and made every plugin hook a silent no-op. Guard on both the
// direct-entry case and that eval-bootstrap case. When imported for its
// exports (tests), require.main is a real, different module, so main() stays
// dormant.
if (require.main === module || require.main === undefined) {
main();
}
module.exports = {
main,
normalizePluginRootForPlatform,
};
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
'use strict';
const MAX_STDIN = 1024 * 1024;
let raw = '';
function run(rawInput) {
try {
const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
const cmd = String(input.tool_input?.command || '');
if (/(npm run build|pnpm build|yarn build)/.test(cmd)) {
return {
stdout: typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput),
stderr: '[Hook] Build completed - async analysis running in background',
exitCode: 0,
};
}
} catch {
// ignore parse errors and pass through
}
return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
}
if (require.main === module) {
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const result = run(raw);
if (result && typeof result === 'object') {
if (result.stderr) {
process.stderr.write(`${result.stderr}\n`);
}
process.stdout.write(String(result.stdout || ''));
process.exitCode = Number.isInteger(result.exitCode) ? result.exitCode : 0;
return;
}
process.stdout.write(String(result));
});
}
module.exports = { run };
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const MAX_STDIN = 1024 * 1024;
let raw = '';
const MODE_CONFIG = {
audit: {
fileName: 'bash-commands.log',
format: command => `[${new Date().toISOString()}] ${command}`,
},
cost: {
fileName: 'cost-tracker.log',
format: command => `[${new Date().toISOString()}] tool=Bash command=${command}`,
},
};
function sanitizeCommand(command) {
return String(command || '')
.replace(/\n/g, ' ')
.replace(/--token[= ][^ ]*/g, '--token=<REDACTED>')
.replace(/Authorization:[: ]*[^ ]*[: ]*[^ ]*/gi, 'Authorization:<REDACTED>')
.replace(/\bAKIA[A-Z0-9]{16}\b/g, '<REDACTED>')
.replace(/\bASIA[A-Z0-9]{16}\b/g, '<REDACTED>')
.replace(/password[= ][^ ]*/gi, 'password=<REDACTED>')
.replace(/\bghp_[A-Za-z0-9_]+\b/g, '<REDACTED>')
.replace(/\bgho_[A-Za-z0-9_]+\b/g, '<REDACTED>')
.replace(/\bghs_[A-Za-z0-9_]+\b/g, '<REDACTED>')
.replace(/\bgithub_pat_[A-Za-z0-9_]+\b/g, '<REDACTED>');
}
function appendLine(filePath, line) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.appendFileSync(filePath, `${line}\n`, 'utf8');
}
function run(rawInput, mode = 'audit') {
const config = MODE_CONFIG[mode];
try {
if (config) {
const input = String(rawInput || '').trim() ? JSON.parse(String(rawInput)) : {};
const command = sanitizeCommand(input.tool_input?.command || '?');
appendLine(path.join(os.homedir(), '.claude', config.fileName), config.format(command));
}
} catch {
// Logging must never block the calling hook.
}
return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
}
function main() {
const mode = process.argv[2];
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
process.stdout.write(run(raw, mode));
});
}
if (require.main === module) {
main();
}
module.exports = {
run,
sanitizeCommand,
};
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env node
'use strict';
const { runPostBash } = require('./bash-hook-dispatcher');
let raw = '';
const MAX_STDIN = 1024 * 1024;
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const result = runPostBash(raw);
if (result.stderr) {
process.stderr.write(result.stderr);
}
process.stdout.write(result.output);
process.exitCode = result.exitCode;
});
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env node
'use strict';
const MAX_STDIN = 1024 * 1024;
let raw = '';
function run(rawInput) {
try {
const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
const cmd = String(input.tool_input?.command || '');
if (/\bgh\s+pr\s+create\b/.test(cmd)) {
const out = String(input.tool_output?.output || '');
const match = out.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/);
if (match) {
const prUrl = match[0];
const repo = prUrl.replace(/https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/\d+/, '$1');
const prNum = prUrl.replace(/.+\/pull\/(\d+)/, '$1');
return {
stdout: typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput),
stderr: [
`[Hook] PR created: ${prUrl}`,
`[Hook] To review: gh pr review ${prNum} --repo ${repo}`,
].join('\n'),
exitCode: 0,
};
}
}
} catch {
// ignore parse errors and pass through
}
return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
}
if (require.main === module) {
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const result = run(raw);
if (result && typeof result === 'object') {
if (result.stderr) {
process.stderr.write(`${result.stderr}\n`);
}
process.stdout.write(String(result.stdout || ''));
process.exit(Number.isInteger(result.exitCode) ? result.exitCode : 0);
return;
}
process.stdout.write(String(result));
});
}
module.exports = { run };
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* PostToolUse Hook: Accumulate edited JS/TS file paths for batch processing
*
* Cross-platform (Windows, macOS, Linux)
*
* Records each edited JS/TS path to a session-scoped temp file (one path per
* line). stop-format-typecheck.js reads this list at Stop time and runs format
* + typecheck once across all edited files, eliminating per-edit latency.
*
* appendFileSync is used so concurrent hook processes write atomically
* without overwriting each other. Deduplication is deferred to the Stop hook.
*/
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const MAX_STDIN = 1024 * 1024;
function getAccumFile() {
const raw =
process.env.CLAUDE_SESSION_ID ||
crypto.createHash('sha1').update(process.cwd()).digest('hex').slice(0, 12);
// Strip path separators and traversal sequences so the value is safe to embed
// directly in a filename regardless of what CLAUDE_SESSION_ID contains.
const sessionId = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
return path.join(os.tmpdir(), `ecc-edited-${sessionId}.txt`);
}
/**
* @param {string} rawInput - Raw JSON string from stdin
* @returns {string} The original input (pass-through)
*/
const JS_TS_EXT = /\.(ts|tsx|js|jsx)$/;
function appendPath(filePath) {
if (filePath && JS_TS_EXT.test(filePath)) {
fs.appendFileSync(getAccumFile(), filePath + '\n', 'utf8');
}
}
/**
* @param {string} rawInput - Raw JSON string from stdin
* @returns {string} The original input (pass-through)
*/
function run(rawInput) {
try {
const input = JSON.parse(rawInput);
// Edit / Write: single file_path
appendPath(input.tool_input?.file_path);
// MultiEdit: array of edits, each with its own file_path
const edits = input.tool_input?.edits;
if (Array.isArray(edits)) {
for (const edit of edits) appendPath(edit?.file_path);
}
} catch {
// Invalid input — pass through
}
return rawInput;
}
if (require.main === module) {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) data += chunk.substring(0, MAX_STDIN - data.length);
});
process.stdin.on('end', () => {
process.stdout.write(run(data));
process.exit(0);
});
}
module.exports = { run };
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env node
/**
* PostToolUse Hook: Warn about console.log statements after edits
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs after Edit tool use. If the edited JS/TS file contains console.log
* statements, warns with line numbers to help remove debug statements
* before committing.
*/
const { readFile } = require('../lib/utils');
const MAX_STDIN = 1024 * 1024; // 1MB limit
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
try {
const input = JSON.parse(data);
const filePath = input.tool_input?.file_path;
if (filePath && /\.(ts|tsx|js|jsx)$/.test(filePath)) {
const content = readFile(filePath);
if (!content) { process.stdout.write(data); process.exit(0); }
const lines = content.split('\n');
const matches = [];
lines.forEach((line, idx) => {
if (/console\.log/.test(line)) {
matches.push((idx + 1) + ': ' + line.trim());
}
});
if (matches.length > 0) {
console.error('[Hook] WARNING: console.log found in ' + filePath);
matches.slice(0, 5).forEach(m => console.error(m));
console.error('[Hook] Remove console.log before committing');
}
}
} catch {
// Invalid input — pass through
}
process.stdout.write(data);
process.exit(0);
});
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env node
/**
* PostToolUse Hook: Auto-format JS/TS files after edits
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs after Edit tool use. If the edited file is a JS/TS file,
* auto-detects the project formatter (Biome or Prettier) by looking
* for config files, then formats accordingly.
*
* For Biome, uses `check --write` (format + lint in one pass) to
* avoid a redundant second invocation from quality-gate.js.
*
* Prefers the local node_modules/.bin binary over npx to skip
* package-resolution overhead (~200-500ms savings per invocation).
*
* Fails silently if no formatter is found or installed.
*/
const { execFileSync, spawnSync } = require('child_process');
const path = require('path');
// Shell metacharacters that cmd.exe interprets as command separators/operators
const UNSAFE_PATH_CHARS = /[&|<>^%!;`()$]/;
const { findProjectRoot, detectFormatter, resolveFormatterBin } = require('../lib/resolve-formatter');
const MAX_STDIN = 1024 * 1024; // 1MB limit
/**
* Core logic — exported so run-with-flags.js can call directly
* without spawning a child process.
*
* @param {string} rawInput - Raw JSON string from stdin
* @returns {string} The original input (pass-through)
*/
function run(rawInput) {
try {
const input = JSON.parse(rawInput);
const filePath = input.tool_input?.file_path;
if (filePath && /\.(ts|tsx|js|jsx)$/.test(filePath)) {
try {
const resolvedFilePath = path.resolve(filePath);
const projectRoot = findProjectRoot(path.dirname(resolvedFilePath));
const formatter = detectFormatter(projectRoot);
if (!formatter) return rawInput;
const resolved = resolveFormatterBin(projectRoot, formatter);
if (!resolved) return rawInput;
// Biome: `check --write` = format + lint in one pass
// Prettier: `--write` = format only
const args = formatter === 'biome' ? [...resolved.prefix, 'check', '--write', resolvedFilePath] : [...resolved.prefix, '--write', resolvedFilePath];
if (process.platform === 'win32' && resolved.bin.endsWith('.cmd')) {
// Windows: .cmd files require shell to execute. Guard against
// command injection by rejecting paths with shell metacharacters.
if (UNSAFE_PATH_CHARS.test(resolvedFilePath)) {
throw new Error('File path contains unsafe shell characters');
}
const result = spawnSync(resolved.bin, args, {
cwd: projectRoot,
shell: true,
stdio: 'pipe',
timeout: 15000
});
if (result.error) throw result.error;
if (typeof result.status === 'number' && result.status !== 0) {
throw new Error(result.stderr?.toString() || `Formatter exited with status ${result.status}`);
}
} else {
execFileSync(resolved.bin, args, {
cwd: projectRoot,
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 15000
});
}
} catch {
// Formatter not installed, file missing, or failed — non-blocking
}
}
} catch {
// Invalid input — pass through
}
return rawInput;
}
// ── stdin entry point (backwards-compatible) ────────────────────
if (require.main === module) {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
data = run(data);
process.stdout.write(data);
process.exit(0);
});
}
module.exports = { run };
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env node
/**
* PostToolUse Hook: TypeScript check after editing .ts/.tsx files
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs after Edit tool use on TypeScript files. Walks up from the file's
* directory to find the nearest tsconfig.json, then runs tsc --noEmit
* and reports only errors related to the edited file.
*/
const { execFileSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const MAX_STDIN = 1024 * 1024; // 1MB limit
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += chunk.substring(0, remaining);
}
});
process.stdin.on("end", () => {
try {
const input = JSON.parse(data);
const filePath = input.tool_input?.file_path;
if (filePath && /\.(ts|tsx)$/.test(filePath)) {
const resolvedPath = path.resolve(filePath);
if (!fs.existsSync(resolvedPath)) {
process.stdout.write(data);
process.exit(0);
}
// Find nearest tsconfig.json by walking up (max 20 levels to prevent infinite loop)
let dir = path.dirname(resolvedPath);
const root = path.parse(dir).root;
let depth = 0;
while (dir !== root && depth < 20) {
if (fs.existsSync(path.join(dir, "tsconfig.json"))) {
break;
}
dir = path.dirname(dir);
depth++;
}
if (fs.existsSync(path.join(dir, "tsconfig.json"))) {
try {
// Use npx.cmd on Windows to avoid shell: true which enables command injection
const npxBin = process.platform === "win32" ? "npx.cmd" : "npx";
execFileSync(npxBin, ["tsc", "--noEmit", "--pretty", "false"], {
cwd: dir,
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 30000,
});
} catch (err) {
// tsc exits non-zero when there are errors — filter to edited file
const output = (err.stdout || "") + (err.stderr || "");
// Compute paths that uniquely identify the edited file.
// tsc output uses paths relative to its cwd (the tsconfig dir),
// so check for the relative path, absolute path, and original path.
// Avoid bare basename matching — it causes false positives when
// multiple files share the same name (e.g., src/utils.ts vs tests/utils.ts).
const relPath = path.relative(dir, resolvedPath);
const candidates = new Set([filePath, resolvedPath, relPath]);
const relevantLines = output
.split("\n")
.filter((line) => {
for (const candidate of candidates) {
if (line.includes(candidate)) return true;
}
return false;
})
.slice(0, 10);
if (relevantLines.length > 0) {
console.error(
"[Hook] TypeScript errors in " + path.basename(filePath) + ":",
);
relevantLines.forEach((line) => console.error(line));
}
}
}
}
} catch {
// Invalid input — pass through
}
process.stdout.write(data);
process.exit(0);
});
+447
View File
@@ -0,0 +1,447 @@
#!/usr/bin/env node
/**
* PreToolUse Hook: Pre-commit Quality Check
*
* Runs quality checks before git commit commands:
* - Detects staged files
* - Runs linter on staged files (if available)
* - Checks for common issues (console.log, TODO, etc.)
* - Validates commit message format (if provided)
*
* Cross-platform (Windows, macOS, Linux)
*
* Exit codes:
* 0 - Success (allow commit)
* 2 - Block commit (quality issues found)
*/
const { spawnSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const MAX_STDIN = 1024 * 1024; // 1MB limit
/**
* Detect staged files for commit
* @returns {string[]} Array of staged file paths
*/
function getStagedFiles() {
const result = spawnSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACMR'], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
});
if (result.status !== 0) {
return [];
}
return result.stdout.trim().split('\n').filter(f => f.length > 0);
}
function getStagedFileContent(filePath) {
const result = spawnSync('git', ['show', `:${filePath}`], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
});
if (result.status !== 0) {
return null;
}
return result.stdout;
}
/**
* Check if a file should be quality-checked
* @param {string} filePath
* @returns {boolean}
*/
function shouldCheckFile(filePath) {
const checkableExtensions = ['.js', '.jsx', '.ts', '.tsx', '.py', '.go', '.rs'];
return checkableExtensions.some(ext => filePath.endsWith(ext));
}
/**
* Find issues in file content
* @param {string} filePath
* @returns {object[]} Array of issues found
*/
function findFileIssues(filePath) {
const issues = [];
try {
const content = getStagedFileContent(filePath);
if (content === null || content === undefined) {
return issues;
}
const lines = content.split('\n');
lines.forEach((line, index) => {
const lineNum = index + 1;
// Check for console.log
if (line.includes('console.log') && !line.trim().startsWith('//') && !line.trim().startsWith('*')) {
issues.push({
type: 'console.log',
message: `console.log found at line ${lineNum}`,
line: lineNum,
severity: 'warning'
});
}
// Check for debugger statements
if (/\bdebugger\b/.test(line) && !line.trim().startsWith('//')) {
issues.push({
type: 'debugger',
message: `debugger statement at line ${lineNum}`,
line: lineNum,
severity: 'error'
});
}
// Check for TODO/FIXME without issue reference
const todoMatch = line.match(/\/\/\s*(TODO|FIXME):?\s*(.+)/);
if (todoMatch && !todoMatch[2].match(/#\d+|issue/i)) {
issues.push({
type: 'todo',
message: `TODO/FIXME without issue reference at line ${lineNum}: "${todoMatch[2].trim()}"`,
line: lineNum,
severity: 'info'
});
}
// Check for hardcoded secrets (basic patterns)
const secretPatterns = [
{ pattern: /sk-[a-zA-Z0-9]{20,}/, name: 'OpenAI API key' },
{ pattern: /ghp_[a-zA-Z0-9]{36}/, name: 'GitHub PAT' },
{ pattern: /AKIA[A-Z0-9]{16}/, name: 'AWS Access Key' },
{ pattern: /api[_-]?key\s*[=:]\s*['"][^'"]+['"]/i, name: 'API key' }
];
for (const { pattern, name } of secretPatterns) {
if (pattern.test(line)) {
issues.push({
type: 'secret',
message: `Potential ${name} exposed at line ${lineNum}`,
line: lineNum,
severity: 'error'
});
}
}
});
} catch {
// File not readable, skip
}
return issues;
}
/**
* Validate commit message format
* @param {string} command
* @returns {object|null} Validation result or null if no message to validate
*/
function validateCommitMessage(command) {
// Extract commit message from command
const messageMatch = command.match(/(?:-m|--message)[=\s]+["']?([^"']+)["']?/);
if (!messageMatch) return null;
const message = messageMatch[1];
const issues = [];
// Check conventional commit format
const conventionalCommit = /^(feat|fix|docs|style|refactor|test|chore|build|ci|perf|revert)(\(.+\))?:\s*.+/;
if (!conventionalCommit.test(message)) {
issues.push({
type: 'format',
message: 'Commit message does not follow conventional commit format',
suggestion: 'Use format: type(scope): description (e.g., "feat(auth): add login flow")'
});
}
// Check message length
if (message.length > 72) {
issues.push({
type: 'length',
message: `Commit message too long (${message.length} chars, max 72)`,
suggestion: 'Keep the first line under 72 characters'
});
}
// Check for lowercase first letter (conventional)
if (conventionalCommit.test(message)) {
const afterColon = message.split(':')[1];
if (afterColon && /^[A-Z]/.test(afterColon.trim())) {
issues.push({
type: 'capitalization',
message: 'Subject should start with lowercase after type',
suggestion: 'Use lowercase for the first letter of the subject'
});
}
}
// Check for trailing period
if (message.endsWith('.')) {
issues.push({
type: 'punctuation',
message: 'Commit message should not end with a period',
suggestion: 'Remove the trailing period'
});
}
return { message, issues };
}
function getPathEnv() {
const pathKey = Object.keys(process.env).find(key => key.toLowerCase() === 'path') || 'PATH';
return process.env[pathKey] || '';
}
function isPathLike(command) {
return command.includes(path.sep) || (process.platform === 'win32' && /[\\/]/.test(command));
}
function getExecutableCandidates(command) {
if (process.platform !== 'win32' || path.extname(command)) {
return [command];
}
const pathExt = process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD';
return [command, ...pathExt.split(';').filter(Boolean).map(ext => `${command}${ext.toLowerCase()}`)];
}
function resolveCommand(command) {
if (isPathLike(command)) {
return getExecutableCandidates(command).find(candidate => fs.existsSync(candidate)) || null;
}
for (const dir of getPathEnv().split(path.delimiter).filter(Boolean)) {
for (const candidate of getExecutableCandidates(path.join(dir, command))) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
}
return null;
}
function runLinterCommand(command, args) {
const useShell = process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(command);
return spawnSync(command, args, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 30000,
shell: useShell
});
}
function commandOutput(result) {
return result.stdout || result.stderr || result.error?.message || '';
}
/**
* Run linter on staged files
* @param {string[]} files
* @returns {object} Lint results
*/
function runLinter(files) {
const jsFiles = files.filter(f => /\.(js|jsx|ts|tsx)$/.test(f));
const pyFiles = files.filter(f => f.endsWith('.py'));
const goFiles = files.filter(f => f.endsWith('.go'));
const results = {
eslint: null,
pylint: null,
golint: null
};
// Run ESLint if available
if (jsFiles.length > 0) {
const eslintBin = process.platform === 'win32' ? 'eslint.cmd' : 'eslint';
const eslintPath = path.join(process.cwd(), 'node_modules', '.bin', eslintBin);
if (fs.existsSync(eslintPath)) {
const result = runLinterCommand(eslintPath, ['--format', 'compact', ...jsFiles]);
results.eslint = {
success: result.status === 0,
output: commandOutput(result)
};
}
}
// Run Pylint if available
if (pyFiles.length > 0) {
try {
const pylintPath = resolveCommand('pylint');
if (!pylintPath) {
results.pylint = null;
} else {
const result = runLinterCommand(pylintPath, ['--output-format=text', ...pyFiles]);
results.pylint = {
success: result.status === 0,
output: commandOutput(result)
};
}
} catch {
// Pylint not available
}
}
// Run golint if available
if (goFiles.length > 0) {
try {
const golintPath = resolveCommand('golint');
if (!golintPath) {
results.golint = null;
} else {
const result = runLinterCommand(golintPath, goFiles);
results.golint = {
success: !result.stdout || result.stdout.trim() === '',
output: commandOutput(result)
};
}
} catch {
// golint not available
}
}
return results;
}
/**
* Core logic — exported for direct invocation
* @param {string} rawInput - Raw JSON string from stdin
* @returns {{output:string, exitCode:number}} Pass-through output and exit code
*/
function evaluate(rawInput) {
try {
const input = JSON.parse(rawInput);
const command = input.tool_input?.command || '';
// Only run for git commit commands
if (!command.includes('git commit')) {
return { output: rawInput, exitCode: 0 };
}
// Check if this is an amend (skip checks for amends to avoid blocking)
if (command.includes('--amend')) {
return { output: rawInput, exitCode: 0 };
}
// Get staged files
const stagedFiles = getStagedFiles();
if (stagedFiles.length === 0) {
console.error('[Hook] No staged files found. Use "git add" to stage files first.');
return { output: rawInput, exitCode: 0 };
}
console.error(`[Hook] Checking ${stagedFiles.length} staged file(s)...`);
// Check each staged file
const filesToCheck = stagedFiles.filter(shouldCheckFile);
let totalIssues = 0;
let errorCount = 0;
let warningCount = 0;
let infoCount = 0;
for (const file of filesToCheck) {
const fileIssues = findFileIssues(file);
if (fileIssues.length > 0) {
console.error(`\n[FILE] ${file}`);
for (const issue of fileIssues) {
const label = issue.severity === 'error' ? 'ERROR' : issue.severity === 'warning' ? 'WARNING' : 'INFO';
console.error(` ${label} Line ${issue.line}: ${issue.message}`);
totalIssues++;
if (issue.severity === 'error') errorCount++;
if (issue.severity === 'warning') warningCount++;
if (issue.severity === 'info') infoCount++;
}
}
}
// Validate commit message if provided
const messageValidation = validateCommitMessage(command);
if (messageValidation && messageValidation.issues.length > 0) {
console.error('\nCommit Message Issues:');
for (const issue of messageValidation.issues) {
console.error(` WARNING ${issue.message}`);
if (issue.suggestion) {
console.error(` TIP ${issue.suggestion}`);
}
totalIssues++;
warningCount++;
}
}
// Run linter
const lintResults = runLinter(filesToCheck);
if (lintResults.eslint && !lintResults.eslint.success) {
console.error('\nESLint Issues:');
console.error(lintResults.eslint.output);
totalIssues++;
errorCount++;
}
if (lintResults.pylint && !lintResults.pylint.success) {
console.error('\nPylint Issues:');
console.error(lintResults.pylint.output);
totalIssues++;
errorCount++;
}
if (lintResults.golint && !lintResults.golint.success) {
console.error('\ngolint Issues:');
console.error(lintResults.golint.output);
totalIssues++;
errorCount++;
}
// Summary
if (totalIssues > 0) {
console.error(`\nSummary: ${totalIssues} issue(s) found (${errorCount} error(s), ${warningCount} warning(s), ${infoCount} info)`);
if (errorCount > 0) {
console.error('\n[Hook] ERROR: Commit blocked due to critical issues. Fix them before committing.');
return { output: rawInput, exitCode: 2 };
} else {
console.error('\n[Hook] WARNING: Warnings found. Consider fixing them, but commit is allowed.');
console.error('[Hook] To bypass these checks, use: git commit --no-verify');
}
} else {
console.error('\n[Hook] PASS: All checks passed!');
}
} catch (error) {
console.error(`[Hook] Error: ${error.message}`);
// Non-blocking on error
}
return { output: rawInput, exitCode: 0 };
}
function run(rawInput) {
const result = evaluate(rawInput);
return {
stdout: result.output,
exitCode: result.exitCode,
};
}
// ── stdin entry point ────────────────────────────────────────────
if (require.main === module) {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) {
const remaining = MAX_STDIN - data.length;
data += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const result = evaluate(data);
process.stdout.write(result.output);
process.exit(result.exitCode);
});
}
module.exports = { run, evaluate };
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env node
'use strict';
const MAX_STDIN = 1024 * 1024;
const path = require('path');
const { splitShellSegments } = require('../lib/shell-split');
const {
extractCommandSubstitutions,
extractSubshellGroups
} = require('../lib/shell-substitution');
const DEV_COMMAND_WORDS = new Set([
'npm',
'pnpm',
'yarn',
'bun',
'npx',
'tmux'
]);
const SKIPPABLE_PREFIX_WORDS = new Set(['env', 'command', 'builtin', 'exec', 'noglob', 'sudo', 'nohup']);
const PREFIX_OPTION_VALUE_WORDS = {
env: new Set(['-u', '-C', '-S', '--unset', '--chdir', '--split-string']),
sudo: new Set([
'-u',
'-g',
'-h',
'-p',
'-r',
'-t',
'-C',
'--user',
'--group',
'--host',
'--prompt',
'--role',
'--type',
'--close-from'
])
};
function readToken(input, startIndex) {
let index = startIndex;
while (index < input.length && /\s/.test(input[index])) index += 1;
if (index >= input.length) return null;
let token = '';
let quote = null;
while (index < input.length) {
const ch = input[index];
if (quote) {
if (ch === quote) {
quote = null;
index += 1;
continue;
}
if (ch === '\\' && quote === '"' && index + 1 < input.length) {
token += input[index + 1];
index += 2;
continue;
}
token += ch;
index += 1;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
index += 1;
continue;
}
if (/\s/.test(ch)) break;
if (ch === '\\' && index + 1 < input.length) {
token += input[index + 1];
index += 2;
continue;
}
token += ch;
index += 1;
}
return { token, end: index };
}
function shouldSkipOptionValue(wrapper, optionToken) {
if (!wrapper || !optionToken || optionToken.includes('=')) return false;
const optionSet = PREFIX_OPTION_VALUE_WORDS[wrapper];
return Boolean(optionSet && optionSet.has(optionToken));
}
function isOptionToken(token) {
return token.startsWith('-') && token.length > 1;
}
function normalizeCommandWord(token) {
if (!token) return '';
const base = path.basename(token).toLowerCase();
return base.replace(/\.(cmd|exe|bat)$/i, '');
}
function getLeadingCommandWord(segment) {
let index = 0;
let activeWrapper = null;
let skipNextValue = false;
while (index < segment.length) {
const parsed = readToken(segment, index);
if (!parsed) return null;
index = parsed.end;
const token = parsed.token;
if (!token) continue;
if (skipNextValue) {
skipNextValue = false;
continue;
}
if (token === '--') {
activeWrapper = null;
continue;
}
if (token === '{' || token === '}') continue;
if (/^[A-Za-z_][A-Za-z0-9_]*=.*/.test(token)) continue;
const normalizedToken = normalizeCommandWord(token);
if (SKIPPABLE_PREFIX_WORDS.has(normalizedToken)) {
activeWrapper = normalizedToken;
continue;
}
if (activeWrapper && isOptionToken(token)) {
if (shouldSkipOptionValue(activeWrapper, token)) {
skipNextValue = true;
}
continue;
}
return normalizedToken;
}
return null;
}
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
const TMUX_LAUNCHER = /^\s*tmux\s+(new|new-session|new-window|split-window)\b/;
// Trailing (?![\w-]) rather than \b: \b treats a hyphen as a word boundary, so
// `dev\b` matches the `dev` prefix of distinct scripts like `dev-setup` /
// `dev-docs` / `dev-build` and wrongly blocks them. The lookahead still matches
// the dev server (`dev`, `dev;`, `dev:ssr`, ...) but not a `dev-<suffix>` script.
const DEV_PATTERN = /\b(npm\s+run\s+dev|pnpm(?:\s+run)?\s+dev|yarn(?:\s+run)?\s+dev|bun(?:\s+run)?\s+dev)(?![\w-])/;
/**
* Collect every command-line segment we should evaluate. Returns the top-level
* segments first, then segments harvested from `$(...)` / backtick command
* substitutions and plain `(...)` subshell groups, recursively.
*
* Without this expansion the leading-command and dev-pattern check below only
* sees the outermost command, so wrappers like `$(npm run dev)` and
* `(npm run dev)` (which still spawn a dev server) sneak past.
*/
function collectCheckSegments(cmd) {
const segments = [...splitShellSegments(cmd)];
const queue = [cmd];
const seen = new Set();
while (queue.length) {
const current = queue.shift();
if (seen.has(current)) continue;
seen.add(current);
for (const body of extractCommandSubstitutions(current)) {
for (const seg of splitShellSegments(body)) segments.push(seg);
queue.push(body);
}
for (const body of extractSubshellGroups(current)) {
for (const seg of splitShellSegments(body)) segments.push(seg);
queue.push(body);
}
}
return segments;
}
function isBlockedDevSegment(segment) {
const commandWord = getLeadingCommandWord(segment);
if (!commandWord || !DEV_COMMAND_WORDS.has(commandWord)) return false;
return DEV_PATTERN.test(segment) && !TMUX_LAUNCHER.test(segment);
}
process.stdin.on('end', () => {
try {
const input = JSON.parse(raw);
const cmd = String(input.tool_input?.command || '');
if (process.platform !== 'win32') {
const segments = collectCheckSegments(cmd);
const hasBlockedDev = segments.some(isBlockedDevSegment);
if (hasBlockedDev) {
console.error('[Hook] BLOCKED: Dev server must run in tmux for log access');
console.error('[Hook] Use: tmux new-session -d -s dev "npm run dev"');
console.error('[Hook] Then: tmux attach -t dev');
process.exit(2);
}
}
} catch {
// ignore parse errors and pass through
}
process.stdout.write(raw);
});
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env node
'use strict';
const { runPreBash } = require('./bash-hook-dispatcher');
let raw = '';
const MAX_STDIN = 1024 * 1024;
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const result = runPreBash(raw);
if (result.stderr) {
process.stderr.write(result.stderr);
}
process.stdout.write(result.output);
process.exitCode = result.exitCode;
});
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env node
'use strict';
const MAX_STDIN = 1024 * 1024;
const { buildPreToolUseAdditionalContext } = require('./pretooluse-visible-output');
let raw = '';
function run(rawInput) {
try {
const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
const cmd = String(input.tool_input?.command || '');
if (/\bgit\s+push\b/.test(cmd)) {
return {
additionalContext: [
'[Hook] Review changes before push...',
'[Hook] Continuing with push (remove this hook to add interactive review)',
],
exitCode: 0,
};
}
} catch {
// ignore parse errors and pass through
}
return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
}
if (require.main === module) {
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const result = run(raw);
if (result && typeof result === 'object') {
if (result.stderr) {
process.stderr.write(`${result.stderr}\n`);
}
if (Object.prototype.hasOwnProperty.call(result, 'additionalContext')) {
process.stdout.write(buildPreToolUseAdditionalContext(result.additionalContext));
} else {
process.stdout.write(String(result.stdout || ''));
}
process.exitCode = Number.isInteger(result.exitCode) ? result.exitCode : 0;
return;
}
process.stdout.write(String(result));
});
}
module.exports = { run };
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env node
'use strict';
const MAX_STDIN = 1024 * 1024;
const { buildPreToolUseAdditionalContext } = require('./pretooluse-visible-output');
let raw = '';
function run(rawInput) {
try {
const input = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
const cmd = String(input.tool_input?.command || '');
if (
process.platform !== 'win32' &&
!process.env.TMUX &&
/(npm (install|test)|pnpm (install|test)|yarn (install|test)?|bun (install|test)|cargo build|make\b|docker\b|pytest|vitest|playwright)/.test(cmd)
) {
return {
additionalContext: [
'[Hook] Consider running in tmux for session persistence',
'[Hook] tmux new -s dev | tmux attach -t dev',
],
exitCode: 0,
};
}
} catch {
// ignore parse errors and pass through
}
return typeof rawInput === 'string' ? rawInput : JSON.stringify(rawInput);
}
if (require.main === module) {
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const result = run(raw);
if (result && typeof result === 'object') {
if (result.stderr) {
process.stderr.write(`${result.stderr}\n`);
}
if (Object.prototype.hasOwnProperty.call(result, 'additionalContext')) {
process.stdout.write(buildPreToolUseAdditionalContext(result.additionalContext));
} else {
process.stdout.write(String(result.stdout || ''));
}
process.exitCode = Number.isInteger(result.exitCode) ? result.exitCode : 0;
return;
}
process.stdout.write(String(result));
});
}
module.exports = { run };
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env node
/**
* PreCompact Hook - Save LLM-generated summary before context compaction
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs before Claude compacts context. Generates a rich LLM summary of the
* current session and writes it to the active session .tmp file so that the
* next session start gets a high-quality summary even after lossy compaction.
*
* Falls back to a plain log entry when transcript_path is unavailable or the
* LLM call fails.
*/
const path = require('path');
const fs = require('fs');
const { getSessionsDir, getDateTimeString, getTimeString, findFiles, ensureDir, appendFile, readFile, writeFile, log } = require('../lib/utils');
const { generateSessionSummary } = require('../lib/llm-summary');
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
const SUMMARY_END_MARKER = '<!-- ECC:SUMMARY:END -->';
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const MAX_STDIN = 1024 * 1024;
let stdinData = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (stdinData.length < MAX_STDIN) {
stdinData += chunk.substring(0, MAX_STDIN - stdinData.length);
}
});
process.stdin.on('end', () => {
main().catch(err => {
log(`[PreCompact] Error: ${err.message}`);
process.exit(0);
});
});
async function main() {
let transcriptPath = null;
try {
const input = JSON.parse(stdinData);
if (input && typeof input.transcript_path === 'string' && input.transcript_path.length > 0) {
transcriptPath = input.transcript_path;
}
} catch {
// stdin not JSON or missing — proceed without transcript
}
const sessionsDir = getSessionsDir();
const compactionLog = path.join(sessionsDir, 'compaction-log.txt');
ensureDir(sessionsDir);
const timestamp = getDateTimeString();
appendFile(compactionLog, `[${timestamp}] Context compaction triggered\n`);
const sessions = findFiles(sessionsDir, '*-session.tmp');
if (sessions.length === 0) {
log('[PreCompact] No active session file found');
process.exit(0);
}
const activeSession = sessions[0].path;
const timeStr = getTimeString();
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
appendFile(activeSession, `\n---\n**[Compaction occurred at ${timeStr}]** - Context was summarized\n`);
log('[PreCompact] No transcript available; logged compaction event only');
process.exit(0);
}
// Generate LLM summary right before compaction — most critical timing
log('[PreCompact] Generating LLM summary before compaction...');
const llmSummary = generateSessionSummary(transcriptPath);
if (!llmSummary) {
appendFile(activeSession, `\n---\n**[Compaction occurred at ${timeStr}]** - Context was summarized\n`);
log('[PreCompact] LLM summary unavailable; logged compaction event only');
process.exit(0);
}
const existing = readFile(activeSession);
if (existing && existing.includes(SUMMARY_START_MARKER) && existing.includes(SUMMARY_END_MARKER)) {
const newBlock = `${SUMMARY_START_MARKER}\n${llmSummary}\n<!-- LLM_SUMMARY:pre-compact:${timeStr} -->\n${SUMMARY_END_MARKER}`;
const updated = existing.replace(new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`), () => newBlock);
writeFile(activeSession, updated);
log('[PreCompact] LLM summary written to session file before compaction');
} else {
appendFile(activeSession, `\n---\n**[Compaction at ${timeStr}]**\n\n${llmSummary}\n`);
log('[PreCompact] LLM summary appended (no summary markers found)');
}
process.exit(0);
}
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env node
/**
* Backward-compatible doc warning hook entrypoint.
* Kept for consumers that still reference pre-write-doc-warn.js directly.
*/
'use strict';
// doc-file-warning.js guards its stdin entrypoint behind require.main; call main() explicitly.
require('./doc-file-warning.js').main();
@@ -0,0 +1,41 @@
#!/usr/bin/env node
'use strict';
function normalizeAdditionalContext(value) {
if (Array.isArray(value)) {
return value
.map(item => String(item || '').trim())
.filter(Boolean)
.join('\n');
}
return String(value || '').trim();
}
function combineAdditionalContext(current, next) {
const currentText = normalizeAdditionalContext(current);
const nextText = normalizeAdditionalContext(next);
if (!currentText) return nextText;
if (!nextText) return currentText;
return `${currentText}\n${nextText}`;
}
function buildPreToolUseAdditionalContext(value) {
const additionalContext = normalizeAdditionalContext(value);
if (!additionalContext) return '';
return JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
additionalContext,
},
});
}
module.exports = {
buildPreToolUseAdditionalContext,
combineAdditionalContext,
normalizeAdditionalContext,
};
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env node
/**
* Quality Gate Hook
*
* Runs lightweight quality checks after file edits.
* - Targets one file when file_path is provided
* - Falls back to no-op when language/tooling is unavailable
*
* For JS/TS files with Biome, this hook is skipped because
* post-edit-format.js already runs `biome check --write`.
* This hook still handles .json/.md files for Biome, and all
* Prettier / Go / Python checks.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { findProjectRoot, detectFormatter, resolveFormatterBin } = require('../lib/resolve-formatter');
const MAX_STDIN = 1024 * 1024;
/**
* Execute a command synchronously, returning the spawnSync result.
*
* @param {string} command - Executable path or name
* @param {string[]} args - Arguments to pass
* @param {string} [cwd] - Working directory (defaults to process.cwd())
* @returns {import('child_process').SpawnSyncReturns<string>}
*/
function exec(command, args, cwd = process.cwd()) {
return spawnSync(command, args, {
cwd,
encoding: 'utf8',
env: process.env,
timeout: 15000
});
}
/**
* Write a message to stderr for logging.
*
* @param {string} msg - Message to log
*/
function log(msg) {
process.stderr.write(`${msg}\n`);
}
/**
* Run quality-gate checks for a single file based on its extension.
* Skips JS/TS files when Biome is configured (handled by post-edit-format).
*
* @param {string} filePath - Path to the edited file
*/
function maybeRunQualityGate(filePath) {
if (!filePath || !fs.existsSync(filePath)) {
return;
}
// Resolve to absolute path so projectRoot-relative comparisons work
filePath = path.resolve(filePath);
const ext = path.extname(filePath).toLowerCase();
const fix = String(process.env.ECC_QUALITY_GATE_FIX || '').toLowerCase() === 'true';
const strict = String(process.env.ECC_QUALITY_GATE_STRICT || '').toLowerCase() === 'true';
if (['.ts', '.tsx', '.js', '.jsx', '.json', '.md'].includes(ext)) {
const projectRoot = findProjectRoot(path.dirname(filePath));
const formatter = detectFormatter(projectRoot);
if (formatter === 'biome') {
// JS/TS already handled by post-edit-format via `biome check --write`
if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) {
return;
}
// .json / .md — still need quality gate
const resolved = resolveFormatterBin(projectRoot, 'biome');
if (!resolved) return;
const args = [...resolved.prefix, 'check', filePath];
if (fix) args.push('--write');
const result = exec(resolved.bin, args, projectRoot);
if (result.status !== 0 && strict) {
log(`[QualityGate] Biome check failed for ${filePath}`);
}
return;
}
if (formatter === 'prettier') {
const resolved = resolveFormatterBin(projectRoot, 'prettier');
if (!resolved) return;
const args = [...resolved.prefix, fix ? '--write' : '--check', filePath];
const result = exec(resolved.bin, args, projectRoot);
if (result.status !== 0 && strict) {
log(`[QualityGate] Prettier check failed for ${filePath}`);
}
return;
}
// No formatter configured — skip
return;
}
if (ext === '.go') {
if (fix) {
const r = exec('gofmt', ['-w', filePath]);
if (r.status !== 0 && strict) {
log(`[QualityGate] gofmt failed for ${filePath}`);
}
} else if (strict) {
const r = exec('gofmt', ['-l', filePath]);
if (r.status !== 0) {
log(`[QualityGate] gofmt failed for ${filePath}`);
} else if (r.stdout && r.stdout.trim()) {
log(`[QualityGate] gofmt check failed for ${filePath}`);
}
}
return;
}
if (ext === '.py') {
const args = ['format'];
if (!fix) args.push('--check');
args.push(filePath);
const r = exec('ruff', args);
if (r.status !== 0 && strict) {
log(`[QualityGate] Ruff check failed for ${filePath}`);
}
}
}
/**
* Core logic — exported so run-with-flags.js can call directly.
*
* @param {string} rawInput - Raw JSON string from stdin
* @returns {string} The original input (pass-through)
*/
function run(rawInput) {
try {
const input = JSON.parse(rawInput);
const filePath = String(input.tool_input?.file_path || '');
maybeRunQualityGate(filePath);
} catch {
// Ignore parse errors.
}
return rawInput;
}
// ── stdin entry point (backwards-compatible) ────────────────────
if (require.main === module) {
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
const result = run(raw);
process.stdout.write(result);
});
}
module.exports = { run };
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
HOOK_ID="${1:-}"
REL_SCRIPT_PATH="${2:-}"
PROFILES_CSV="${3:-standard,strict}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "${SCRIPT_DIR}/../.." && pwd)}"
# Preserve stdin for passthrough or script execution
INPUT="$(cat)"
if [[ -z "$HOOK_ID" || -z "$REL_SCRIPT_PATH" ]]; then
printf '%s' "$INPUT"
exit 0
fi
# Ask Node helper if this hook is enabled
ENABLED="$(node "${PLUGIN_ROOT}/scripts/hooks/check-hook-enabled.js" "$HOOK_ID" "$PROFILES_CSV" 2>/dev/null || echo yes)"
if [[ "$ENABLED" != "yes" ]]; then
printf '%s' "$INPUT"
exit 0
fi
SCRIPT_PATH="${PLUGIN_ROOT}/${REL_SCRIPT_PATH}"
if [[ ! -f "$SCRIPT_PATH" ]]; then
echo "[Hook] Script not found for ${HOOK_ID}: ${SCRIPT_PATH}" >&2
printf '%s' "$INPUT"
exit 0
fi
# Extract phase prefix from hook ID (e.g., "pre:observe" -> "pre", "post:observe" -> "post")
# This is needed by scripts like observe.sh that behave differently for PreToolUse vs PostToolUse
HOOK_PHASE="${HOOK_ID%%:*}"
printf '%s' "$INPUT" | "$SCRIPT_PATH" "$HOOK_PHASE"
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env node
/**
* Executes a hook script only when enabled by ECC hook profile flags.
*
* Usage:
* node run-with-flags.js <hookId> <scriptRelativePath> [profilesCsv]
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { isHookEnabled, isDryRun } = require('../lib/hook-flags');
const { buildPreToolUseAdditionalContext } = require('./pretooluse-visible-output');
const MAX_STDIN = 1024 * 1024;
function readStdinRaw() {
return new Promise(resolve => {
let raw = '';
let truncated = false;
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
if (chunk.length > remaining) {
truncated = true;
}
} else {
truncated = true;
}
});
process.stdin.on('end', () => resolve({ raw, truncated }));
process.stdin.on('error', () => resolve({ raw, truncated }));
});
}
function writeStderr(stderr) {
if (typeof stderr !== 'string' || stderr.length === 0) {
return;
}
process.stderr.write(stderr.endsWith('\n') ? stderr : `${stderr}\n`);
}
/**
* Write stdout fully, then exit. `process.exit()` immediately after
* `process.stdout.write()` drops anything beyond the ~64KB pipe buffer,
* which cut large pass-through payloads mid-JSON and made the harness
* treat the hook as failed (#2222). The write callback fires only after
* the chunk is flushed to the pipe.
*/
function exitWithStdout(text, exitCode) {
if (typeof text !== 'string' || text.length === 0) {
process.exit(exitCode);
}
process.stdout.write(text, () => process.exit(exitCode));
}
function resolveHookResult(raw, output) {
if (typeof output === 'string' || Buffer.isBuffer(output)) {
return { stdout: String(output), exitCode: 0 };
}
if (output && typeof output === 'object') {
writeStderr(output.stderr);
const exitCode = Number.isInteger(output.exitCode) ? output.exitCode : 0;
if (Object.prototype.hasOwnProperty.call(output, 'additionalContext')) {
return { stdout: buildPreToolUseAdditionalContext(output.additionalContext), exitCode };
}
if (Object.prototype.hasOwnProperty.call(output, 'stdout')) {
return { stdout: String(output.stdout ?? ''), exitCode };
}
return { stdout: exitCode === 0 ? raw : '', exitCode };
}
return { stdout: raw, exitCode: 0 };
}
function resolveLegacySpawnStdout(raw, result) {
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
if (stdout) {
return stdout;
}
if (Number.isInteger(result.status) && result.status === 0) {
return raw;
}
return '';
}
function getPluginRoot() {
if (process.env.CLAUDE_PLUGIN_ROOT && process.env.CLAUDE_PLUGIN_ROOT.trim()) {
return process.env.CLAUDE_PLUGIN_ROOT;
}
return path.resolve(__dirname, '..', '..');
}
//Safely extract target context from hook stdin JSON for dry-run preview.
function extractTargetContext(raw) {
const result = { tool: '', filePath: '', command: '' };
if (!raw || typeof raw !== 'string') return result;
try {
const payload = JSON.parse(raw);
if (payload && typeof payload === 'object') {
result.tool = String(payload.tool || '');
const input = payload.tool_input;
if (input && typeof input === 'object') {
result.filePath = String(input.file_path || input.path || '');
result.command = String(input.command || '');
}
}
} catch {
// best-effort field extraction; ignore malformed input
}
return result;
}
// Build the [DryRun] preview line for stderr.
function buildDryRunPreview(hookId, relScriptPath, profilesCsv, raw) {
const ctx = extractTargetContext(raw);
const parts = [`[DryRun] Hook "${hookId}" would execute: ${relScriptPath}`, `(enabled=true, profiles=${profilesCsv || 'default'})`];
if (ctx.tool) {
parts.push(`tool=${ctx.tool}`);
}
if (ctx.filePath) {
parts.push(`target=${ctx.filePath}`);
}
if (ctx.command) {
parts.push(`command=${ctx.command}`);
}
return parts.join(' ') + '\n';
}
async function main() {
const [, , hookId, relScriptPath, profilesCsv] = process.argv;
const { raw, truncated } = await readStdinRaw();
// Oversized payloads: never echo the truncated string — a JSON document
// cut mid-stream is treated by the harness as a hook failure, blocking the
// tool call (#2222). Empty stdout + exit 0 means "no opinion", so
// pass-through paths fail open. The hook itself still runs and receives
// the truncated flag (run() context / ECC_HOOK_INPUT_TRUNCATED), so
// security hooks like config-protection can still choose to block.
const sanitizeEcho = text => (truncated && text === raw ? '' : text);
if (truncated) {
process.stderr.write(`[Hook] stdin exceeded ${MAX_STDIN} bytes for ${hookId || 'unknown'}; suppressing pass-through (fail-open unless the hook blocks)\n`);
}
if (!hookId || !relScriptPath) {
exitWithStdout(sanitizeEcho(raw), 0);
return;
}
if (!isHookEnabled(hookId, { profiles: profilesCsv })) {
exitWithStdout(sanitizeEcho(raw), 0);
return;
}
if (isDryRun()) {
const preview = buildDryRunPreview(hookId, relScriptPath, profilesCsv, raw);
process.stderr.write(preview);
process.stdout.write(raw);
process.exit(0);
}
const pluginRoot = getPluginRoot();
const resolvedRoot = path.resolve(pluginRoot);
const scriptPath = path.resolve(pluginRoot, relScriptPath);
// Prevent path traversal outside the plugin root
if (!scriptPath.startsWith(resolvedRoot + path.sep)) {
process.stderr.write(`[Hook] Path traversal rejected for ${hookId}: ${scriptPath}\n`);
exitWithStdout(sanitizeEcho(raw), 0);
return;
}
if (!fs.existsSync(scriptPath)) {
process.stderr.write(`[Hook] Script not found for ${hookId}: ${scriptPath}\n`);
exitWithStdout(sanitizeEcho(raw), 0);
return;
}
// Prefer direct require() when the hook exports a run(rawInput) function.
// This eliminates one Node.js process spawn (~50-100ms savings per hook).
//
// SAFETY: Only require() hooks that export run(). Legacy hooks execute
// side effects at module scope (stdin listeners, process.exit, main() calls)
// which would interfere with the parent process or cause double execution.
let hookModule;
const src = fs.readFileSync(scriptPath, 'utf8');
const hasRunExport = /\bmodule\.exports\b/.test(src) && /\brun\b/.test(src);
if (hasRunExport) {
try {
hookModule = require(scriptPath);
} catch (requireErr) {
process.stderr.write(`[Hook] require() failed for ${hookId}: ${requireErr.message}\n`);
// Fall through to legacy spawnSync path
}
}
if (hookModule && typeof hookModule.run === 'function') {
try {
const output = hookModule.run(raw, {
hookId,
pluginRoot,
scriptPath,
truncated,
maxStdin: MAX_STDIN
});
const result = resolveHookResult(raw, output);
exitWithStdout(sanitizeEcho(result.stdout), result.exitCode);
} catch (runErr) {
process.stderr.write(`[Hook] run() error for ${hookId}: ${runErr.message}\n`);
exitWithStdout(sanitizeEcho(raw), 0);
}
return;
}
// Legacy path: spawn a child Node process for hooks without run() export
const result = spawnSync(process.execPath, [scriptPath], {
input: raw,
encoding: 'utf8',
env: {
...process.env,
CLAUDE_PLUGIN_ROOT: pluginRoot,
ECC_PLUGIN_ROOT: pluginRoot,
ECC_HOOK_ID: hookId,
ECC_HOOK_INPUT_TRUNCATED: truncated ? '1' : '0',
ECC_HOOK_INPUT_MAX_BYTES: String(MAX_STDIN)
},
cwd: process.cwd(),
timeout: 30000
});
const legacyStdout = sanitizeEcho(resolveLegacySpawnStdout(raw, result));
if (result.stderr) process.stderr.write(result.stderr);
if (result.error || result.signal || result.status === null) {
const failureDetail = result.error ? result.error.message : result.signal ? `terminated by signal ${result.signal}` : 'missing exit status';
writeStderr(`[Hook] legacy hook execution failed for ${hookId}: ${failureDetail}`);
exitWithStdout(legacyStdout, 1);
return;
}
exitWithStdout(legacyStdout, Number.isInteger(result.status) ? result.status : 0);
}
main().catch(err => {
process.stderr.write(`[Hook] run-with-flags error: ${err.message}\n`);
process.exit(0);
});
+639
View File
@@ -0,0 +1,639 @@
#!/usr/bin/env node
/**
* Session Activity Tracker Hook
*
* PostToolUse hook that records sanitized per-tool activity to
* ~/.claude/metrics/tool-usage.jsonl for ECC2 metric sync.
*/
'use strict';
const crypto = require('crypto');
const path = require('path');
const { spawnSync } = require('child_process');
const {
appendFile,
getClaudeDir,
stripAnsi,
} = require('../lib/utils');
const MAX_STDIN = 1024 * 1024;
const METRICS_FILE_NAME = 'tool-usage.jsonl';
const FILE_PATH_KEYS = new Set([
'file_path',
'file_paths',
'source_path',
'destination_path',
'old_file_path',
'new_file_path',
]);
function redactSecrets(value) {
return String(value || '')
.replace(/\n/g, ' ')
.replace(/--token[= ][^ ]*/g, '--token=<REDACTED>')
.replace(/Authorization:[: ]*[^ ]*[: ]*[^ ]*/gi, 'Authorization:<REDACTED>')
.replace(/\bAKIA[A-Z0-9]{16}\b/g, '<REDACTED>')
.replace(/\bASIA[A-Z0-9]{16}\b/g, '<REDACTED>')
.replace(/password[= ][^ ]*/gi, 'password=<REDACTED>')
.replace(/\bghp_[A-Za-z0-9_]+\b/g, '<REDACTED>')
.replace(/\bgho_[A-Za-z0-9_]+\b/g, '<REDACTED>')
.replace(/\bghs_[A-Za-z0-9_]+\b/g, '<REDACTED>')
.replace(/\bgithub_pat_[A-Za-z0-9_]+\b/g, '<REDACTED>');
}
function truncateSummary(value, maxLength = 220) {
const normalized = stripAnsi(redactSecrets(value)).trim().replace(/\s+/g, ' ');
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 3)}...`;
}
function sanitizeParamValue(value, depth = 0) {
if (depth >= 4) {
return '[Truncated]';
}
if (value === null || value === undefined) {
return value;
}
if (typeof value === 'string') {
return truncateSummary(value, 160);
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value;
}
if (Array.isArray(value)) {
return value.slice(0, 8).map(entry => sanitizeParamValue(entry, depth + 1));
}
if (typeof value === 'object') {
const output = {};
for (const [key, nested] of Object.entries(value).slice(0, 20)) {
output[key] = sanitizeParamValue(nested, depth + 1);
}
return output;
}
return truncateSummary(String(value), 160);
}
function sanitizeInputParams(toolInput) {
if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) {
return '{}';
}
try {
return JSON.stringify(sanitizeParamValue(toolInput));
} catch {
return '{}';
}
}
function pushPathCandidate(paths, value) {
const candidate = String(value || '').trim();
if (!candidate) {
return;
}
if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) {
return;
}
if (!paths.includes(candidate)) {
paths.push(candidate);
}
}
function pushFileEvent(events, value, action, diffPreview, patchPreview) {
const candidate = String(value || '').trim();
if (!candidate) {
return;
}
if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) {
return;
}
const normalizedDiffPreview = typeof diffPreview === 'string' && diffPreview.trim()
? diffPreview.trim()
: undefined;
const normalizedPatchPreview = typeof patchPreview === 'string' && patchPreview.trim()
? patchPreview.trim()
: undefined;
if (!events.some(event =>
event.path === candidate
&& event.action === action
&& (event.diff_preview || undefined) === normalizedDiffPreview
&& (event.patch_preview || undefined) === normalizedPatchPreview
)) {
const event = { path: candidate, action };
if (normalizedDiffPreview) {
event.diff_preview = normalizedDiffPreview;
}
if (normalizedPatchPreview) {
event.patch_preview = normalizedPatchPreview;
}
events.push(event);
}
}
function sanitizeDiffText(value, maxLength = 96) {
if (typeof value !== 'string' || !value.trim()) {
return '';
}
return truncateSummary(value, maxLength);
}
function sanitizePatchLines(value, maxLines = 4, maxLineLength = 120) {
if (typeof value !== 'string' || !value.trim()) {
return [];
}
return stripAnsi(redactSecrets(value))
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean)
.slice(0, maxLines)
.map(line => line.length <= maxLineLength ? line : `${line.slice(0, maxLineLength - 3)}...`);
}
function buildReplacementPreview(oldValue, newValue) {
const before = sanitizeDiffText(oldValue);
const after = sanitizeDiffText(newValue);
if (!before && !after) {
return undefined;
}
if (!before) {
return `-> ${after}`;
}
if (!after) {
return `${before} ->`;
}
return `${before} -> ${after}`;
}
function buildCreationPreview(content) {
const normalized = sanitizeDiffText(content);
if (!normalized) {
return undefined;
}
return `+ ${normalized}`;
}
function buildPatchPreviewFromReplacement(oldValue, newValue) {
const beforeLines = sanitizePatchLines(oldValue);
const afterLines = sanitizePatchLines(newValue);
if (beforeLines.length === 0 && afterLines.length === 0) {
return undefined;
}
const lines = ['@@'];
for (const line of beforeLines) {
lines.push(`- ${line}`);
}
for (const line of afterLines) {
lines.push(`+ ${line}`);
}
return lines.join('\n');
}
function buildPatchPreviewFromContent(content, prefix) {
const lines = sanitizePatchLines(content);
if (lines.length === 0) {
return undefined;
}
return lines.map(line => `${prefix} ${line}`).join('\n');
}
function buildDiffPreviewFromPatchPreview(patchPreview) {
if (typeof patchPreview !== 'string' || !patchPreview.trim()) {
return undefined;
}
const lines = patchPreview
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean);
const removed = lines.find(line => line.startsWith('- ') || line.startsWith('-'));
const added = lines.find(line => line.startsWith('+ ') || line.startsWith('+'));
if (!removed && !added) {
return undefined;
}
const before = removed ? removed.replace(/^- ?/, '') : '';
const after = added ? added.replace(/^\+ ?/, '') : '';
if (before && after) {
return `${before} -> ${after}`;
}
if (before) {
return `${before} ->`;
}
return `-> ${after}`;
}
function inferDefaultFileAction(toolName) {
const normalized = String(toolName || '').trim().toLowerCase();
if (normalized.includes('read')) {
return 'read';
}
if (normalized.includes('write')) {
return 'create';
}
if (normalized.includes('edit')) {
return 'modify';
}
if (normalized.includes('delete') || normalized.includes('remove')) {
return 'delete';
}
if (normalized.includes('move') || normalized.includes('rename')) {
return 'move';
}
return 'touch';
}
function actionForFileKey(toolName, key) {
if (key === 'source_path' || key === 'old_file_path') {
return 'move';
}
if (key === 'destination_path' || key === 'new_file_path') {
return 'move';
}
return inferDefaultFileAction(toolName);
}
function collectFilePaths(value, paths) {
if (!value) {
return;
}
if (Array.isArray(value)) {
for (const entry of value) {
collectFilePaths(entry, paths);
}
return;
}
if (typeof value === 'string') {
pushPathCandidate(paths, value);
return;
}
if (typeof value !== 'object') {
return;
}
for (const [key, nested] of Object.entries(value)) {
if (FILE_PATH_KEYS.has(key)) {
collectFilePaths(nested, paths);
continue;
}
if (nested && (Array.isArray(nested) || typeof nested === 'object')) {
collectFilePaths(nested, paths);
}
}
}
function extractFilePaths(toolInput) {
const paths = [];
if (!toolInput || typeof toolInput !== 'object') {
return paths;
}
collectFilePaths(toolInput, paths);
return paths;
}
function fileEventDiffPreview(toolName, value, action) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
if (typeof value.old_string === 'string' || typeof value.new_string === 'string') {
return buildReplacementPreview(value.old_string, value.new_string);
}
if (action === 'create') {
return buildCreationPreview(value.content || value.file_text || value.text);
}
return undefined;
}
function fileEventPatchPreview(value, action) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
if (typeof value.old_string === 'string' || typeof value.new_string === 'string') {
return buildPatchPreviewFromReplacement(value.old_string, value.new_string);
}
if (action === 'create') {
return buildPatchPreviewFromContent(value.content || value.file_text || value.text, '+');
}
if (action === 'delete') {
return buildPatchPreviewFromContent(value.content || value.old_string || value.file_text, '-');
}
return undefined;
}
function runGit(args, cwd) {
const result = spawnSync('git', args, {
cwd,
encoding: 'utf8',
timeout: 2500,
});
if (result.error || result.status !== 0) {
return null;
}
return String(result.stdout || '').trim();
}
function gitRepoRoot(cwd) {
return runGit(['rev-parse', '--show-toplevel'], cwd);
}
const MAX_RELEVANT_PATCH_LINES = 6;
function candidateGitPaths(repoRoot, filePath) {
const resolvedRepoRoot = path.resolve(repoRoot);
const candidates = [];
const pushCandidate = value => {
const candidate = String(value || '').trim();
if (!candidate || candidates.includes(candidate)) {
return;
}
candidates.push(candidate);
};
const absoluteCandidates = path.isAbsolute(filePath)
? [path.resolve(filePath)]
: [
path.resolve(resolvedRepoRoot, filePath),
path.resolve(process.cwd(), filePath),
];
for (const absolute of absoluteCandidates) {
const relative = path.relative(resolvedRepoRoot, absolute);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
continue;
}
pushCandidate(relative);
pushCandidate(relative.split(path.sep).join('/'));
pushCandidate(absolute);
pushCandidate(absolute.split(path.sep).join('/'));
}
return candidates;
}
function patchPreviewFromGitDiff(repoRoot, pathCandidates) {
for (const candidate of pathCandidates) {
const patch = runGit(
['diff', '--no-ext-diff', '--no-color', '--unified=1', '--', candidate],
repoRoot
);
if (!patch) {
continue;
}
const relevant = patch
.split(/\r?\n/)
.filter(line =>
line.startsWith('@@')
|| (line.startsWith('+') && !line.startsWith('+++'))
|| (line.startsWith('-') && !line.startsWith('---'))
)
.slice(0, MAX_RELEVANT_PATCH_LINES);
if (relevant.length > 0) {
return relevant.join('\n');
}
}
return undefined;
}
function trackedInGit(repoRoot, pathCandidates) {
return pathCandidates.some(candidate =>
runGit(['ls-files', '--error-unmatch', '--', candidate], repoRoot) !== null
);
}
function enrichFileEventFromWorkingTree(toolName, event) {
if (!event || typeof event !== 'object' || !event.path) {
return event;
}
const repoRoot = gitRepoRoot(process.cwd());
if (!repoRoot) {
return event;
}
const pathCandidates = candidateGitPaths(repoRoot, event.path);
if (pathCandidates.length === 0) {
return event;
}
const tool = String(toolName || '').trim().toLowerCase();
const tracked = trackedInGit(repoRoot, pathCandidates);
const patchPreview = patchPreviewFromGitDiff(repoRoot, pathCandidates) || event.patch_preview;
const diffPreview = buildDiffPreviewFromPatchPreview(patchPreview) || event.diff_preview;
if (tool.includes('write')) {
return {
...event,
action: tracked ? 'modify' : event.action,
diff_preview: diffPreview,
patch_preview: patchPreview,
};
}
if (tracked && patchPreview) {
return {
...event,
diff_preview: diffPreview,
patch_preview: patchPreview,
};
}
return event;
}
function collectFileEvents(toolName, value, events, key = null, parentValue = null) {
if (!value) {
return;
}
if (Array.isArray(value)) {
for (const entry of value) {
collectFileEvents(toolName, entry, events, key, parentValue);
}
return;
}
if (typeof value === 'string') {
if (key && FILE_PATH_KEYS.has(key)) {
const action = actionForFileKey(toolName, key);
pushFileEvent(
events,
value,
action,
fileEventDiffPreview(toolName, parentValue, action),
fileEventPatchPreview(parentValue, action)
);
}
return;
}
if (typeof value !== 'object') {
return;
}
for (const [nestedKey, nested] of Object.entries(value)) {
if (FILE_PATH_KEYS.has(nestedKey)) {
collectFileEvents(toolName, nested, events, nestedKey, value);
continue;
}
if (nested && (Array.isArray(nested) || typeof nested === 'object')) {
collectFileEvents(toolName, nested, events, null, nested);
}
}
}
function extractFileEvents(toolName, toolInput) {
const events = [];
if (!toolInput || typeof toolInput !== 'object') {
return events;
}
collectFileEvents(toolName, toolInput, events);
return events;
}
function summarizeInput(toolName, toolInput, filePaths) {
if (toolName === 'Bash') {
return truncateSummary(toolInput?.command || 'bash');
}
if (filePaths.length > 0) {
return truncateSummary(`${toolName} ${filePaths.join(', ')}`);
}
if (toolInput && typeof toolInput === 'object') {
const shallow = {};
for (const [key, value] of Object.entries(toolInput)) {
if (value === null || value === undefined) {
continue;
}
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
shallow[key] = value;
}
}
const serialized = Object.keys(shallow).length > 0 ? JSON.stringify(shallow) : toolName;
return truncateSummary(serialized);
}
return truncateSummary(toolName);
}
function summarizeOutput(toolOutput) {
if (toolOutput === null || toolOutput === undefined) {
return '';
}
if (typeof toolOutput === 'string') {
return truncateSummary(toolOutput);
}
if (typeof toolOutput === 'object' && typeof toolOutput.output === 'string') {
return truncateSummary(toolOutput.output);
}
return truncateSummary(JSON.stringify(toolOutput));
}
function buildActivityRow(input, env = process.env) {
const hookEvent = String(env.CLAUDE_HOOK_EVENT_NAME || '').trim();
if (hookEvent && hookEvent !== 'PostToolUse') {
return null;
}
const toolName = String(input?.tool_name || '').trim();
const sessionId = String(env.ECC_SESSION_ID || env.CLAUDE_SESSION_ID || '').trim();
if (!toolName || !sessionId) {
return null;
}
const toolInput = input?.tool_input || {};
const fileEvents = extractFileEvents(toolName, toolInput).map(event =>
enrichFileEventFromWorkingTree(toolName, event)
);
const filePaths = fileEvents.length > 0
? [...new Set(fileEvents.map(event => event.path))]
: extractFilePaths(toolInput);
return {
id: `tool-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`,
timestamp: new Date().toISOString(),
session_id: sessionId,
tool_name: toolName,
input_summary: summarizeInput(toolName, toolInput, filePaths),
input_params_json: sanitizeInputParams(toolInput),
output_summary: summarizeOutput(input?.tool_output),
duration_ms: 0,
file_paths: filePaths,
file_events: fileEvents,
};
}
function run(rawInput) {
try {
const input = rawInput.trim() ? JSON.parse(rawInput) : {};
const row = buildActivityRow(input);
if (row) {
appendFile(
path.join(getClaudeDir(), 'metrics', METRICS_FILE_NAME),
`${JSON.stringify(row)}\n`
);
}
} catch {
// Keep hook non-blocking.
}
return rawInput;
}
function main() {
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
process.stdout.write(run(raw));
});
}
if (require.main === module) {
main();
}
module.exports = {
buildActivityRow,
extractFileEvents,
extractFilePaths,
summarizeInput,
summarizeOutput,
run,
};
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env node
'use strict';
/**
* Session end marker hook - performs lightweight observer cleanup and
* outputs stdin to stdout unchanged. Exports run() for in-process execution.
*/
const {
resolveProjectContext,
removeSessionLease,
listSessionLeases,
stopObserverForContext,
resolveSessionId
} = require('../lib/observer-sessions');
function log(message) {
process.stderr.write(`[SessionEnd] ${message}\n`);
}
function run(rawInput) {
const output = rawInput || '';
const sessionId = resolveSessionId();
if (!sessionId) {
log('No CLAUDE_SESSION_ID available; skipping observer cleanup');
return output;
}
try {
const observerContext = resolveProjectContext();
removeSessionLease(observerContext, sessionId);
const remainingLeases = listSessionLeases(observerContext);
if (remainingLeases.length === 0) {
if (stopObserverForContext(observerContext)) {
log(`Stopped observer for project ${observerContext.projectId} after final session lease ended`);
} else {
log(`No running observer to stop for project ${observerContext.projectId}`);
}
} else {
log(`Retained observer for project ${observerContext.projectId}; ${remainingLeases.length} session lease(s) remain`);
}
} catch (err) {
log(`Observer cleanup skipped: ${err.message}`);
}
return output;
}
// Legacy CLI execution (when run directly)
if (require.main === module) {
const MAX_STDIN = 1024 * 1024;
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
process.stdout.write(run(raw));
});
}
module.exports = { run };
+336
View File
@@ -0,0 +1,336 @@
#!/usr/bin/env node
/**
* Stop Hook (Session End) - Persist learnings during active sessions
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs on Stop events (after each response). Extracts a meaningful summary
* from the session transcript (via stdin JSON transcript_path) and updates a
* session file for cross-session continuity.
*/
const path = require('path');
const fs = require('fs');
const { getSessionsDir, getDateString, getTimeString, getSessionIdShort, sanitizeSessionId, getProjectName, ensureDir, readFile, writeFile, runCommand, stripAnsi, log } = require('../lib/utils');
const { generateSessionSummary, getContextRemainingPct, getContextThreshold } = require('../lib/llm-summary');
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
const SUMMARY_END_MARKER = '<!-- ECC:SUMMARY:END -->';
const SESSION_SEPARATOR = '\n---\n';
/**
* Extract a meaningful summary from the session transcript.
* Reads the JSONL transcript and pulls out key information:
* - User messages (tasks requested)
* - Tools used
* - Files modified
*/
function extractSessionSummary(transcriptPath) {
const content = readFile(transcriptPath);
if (!content) return null;
const lines = content.split('\n').filter(Boolean);
const userMessages = [];
const toolsUsed = new Set();
const filesModified = new Set();
let parseErrors = 0;
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Collect user messages (first 200 chars each)
if (entry.type === 'user' || entry.role === 'user' || entry.message?.role === 'user') {
// Support both direct content and nested message.content (Claude Code JSONL format)
const rawContent = entry.message?.content ?? entry.content;
const text = typeof rawContent === 'string' ? rawContent : Array.isArray(rawContent) ? rawContent.map(c => (c && c.text) || '').join(' ') : '';
const cleaned = stripAnsi(text).trim();
if (cleaned) {
userMessages.push(cleaned.slice(0, 200));
}
}
// Collect tool names and modified files (direct tool_use entries)
if (entry.type === 'tool_use' || entry.tool_name) {
const toolName = entry.tool_name || entry.name || '';
if (toolName) toolsUsed.add(toolName);
const filePath = entry.tool_input?.file_path || entry.input?.file_path || '';
if (filePath && (toolName === 'Edit' || toolName === 'Write')) {
filesModified.add(filePath);
}
}
// Extract tool uses from assistant message content blocks (Claude Code JSONL format)
if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) {
for (const block of entry.message.content) {
if (block.type === 'tool_use') {
const toolName = block.name || '';
if (toolName) toolsUsed.add(toolName);
const filePath = block.input?.file_path || '';
if (filePath && (toolName === 'Edit' || toolName === 'Write')) {
filesModified.add(filePath);
}
}
}
}
} catch {
parseErrors++;
}
}
if (parseErrors > 0) {
log(`[SessionEnd] Skipped ${parseErrors}/${lines.length} unparseable transcript lines`);
}
if (userMessages.length === 0) return null;
return {
userMessages: userMessages.slice(-10), // Last 10 user messages
toolsUsed: Array.from(toolsUsed).slice(0, 20),
filesModified: Array.from(filesModified).slice(0, 30),
totalMessages: userMessages.length
};
}
// Read hook input from stdin (Claude Code provides transcript_path via stdin JSON)
const MAX_STDIN = 1024 * 1024;
let stdinData = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (stdinData.length < MAX_STDIN) {
const remaining = MAX_STDIN - stdinData.length;
stdinData += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
runMain();
});
function runMain() {
main().catch(err => {
console.error('[SessionEnd] Error:', err.message);
process.exit(0);
});
}
function getSessionMetadata() {
const branchResult = runCommand('git rev-parse --abbrev-ref HEAD');
return {
project: getProjectName() || 'unknown',
branch: branchResult.success ? branchResult.output : 'unknown',
worktree: process.cwd()
};
}
function extractHeaderField(header, label) {
const match = header.match(new RegExp(`\\*\\*${escapeRegExp(label)}:\\*\\*\\s*(.+)$`, 'm'));
return match ? match[1].trim() : null;
}
function buildSessionHeader(today, currentTime, metadata, existingContent = '') {
const headingMatch = existingContent.match(/^#\s+.+$/m);
const heading = headingMatch ? headingMatch[0] : `# Session: ${today}`;
const date = extractHeaderField(existingContent, 'Date') || today;
const started = extractHeaderField(existingContent, 'Started') || currentTime;
return [
heading,
`**Date:** ${date}`,
`**Started:** ${started}`,
`**Last Updated:** ${currentTime}`,
`**Project:** ${metadata.project}`,
`**Branch:** ${metadata.branch}`,
`**Worktree:** ${metadata.worktree}`,
''
].join('\n');
}
function mergeSessionHeader(content, today, currentTime, metadata) {
const separatorIndex = content.indexOf(SESSION_SEPARATOR);
if (separatorIndex === -1) {
return null;
}
const existingHeader = content.slice(0, separatorIndex);
const body = content.slice(separatorIndex + SESSION_SEPARATOR.length);
const nextHeader = buildSessionHeader(today, currentTime, metadata, existingHeader);
return `${nextHeader}${SESSION_SEPARATOR}${body}`;
}
async function main() {
// Parse stdin JSON to get transcript_path; fall back to env var on missing,
// empty, or non-string values as well as on malformed JSON.
let transcriptPath = null;
try {
const input = JSON.parse(stdinData);
if (input && typeof input.transcript_path === 'string' && input.transcript_path.length > 0) {
transcriptPath = input.transcript_path;
}
} catch {
// Malformed stdin: fall through to the env-var fallback below.
}
if (!transcriptPath) {
const envTranscriptPath = process.env.CLAUDE_TRANSCRIPT_PATH;
if (typeof envTranscriptPath === 'string' && envTranscriptPath.length > 0) {
transcriptPath = envTranscriptPath;
}
}
const sessionsDir = getSessionsDir();
const today = getDateString();
// Derive shortId from transcript_path UUID when available, using the SAME
// last-8-chars convention as getSessionIdShort(sessionId.slice(-8)). This keeps
// backward compatibility for normal sessions (the derived shortId matches what
// getSessionIdShort() would have produced from the same UUID), while making
// every session map to a unique filename based on its own transcript UUID.
//
// Without this, a parent session and any `claude -p ...` subprocess spawned by
// another Stop hook share the project-name fallback filename, and the subprocess
// overwrites the parent's summary. See issue #1494 for full repro details.
let shortId = null;
if (transcriptPath) {
const m = path.basename(transcriptPath).match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
if (m) {
// Run through sanitizeSessionId() for byte-for-byte parity with
// getSessionIdShort(sessionId.slice(-8)).
shortId = sanitizeSessionId(m[1].slice(-8).toLowerCase());
}
}
if (!shortId) {
shortId = getSessionIdShort();
}
const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`);
const sessionMetadata = getSessionMetadata();
ensureDir(sessionsDir);
const currentTime = getTimeString();
// Try to extract summary from transcript
let summary = null;
if (transcriptPath) {
if (fs.existsSync(transcriptPath)) {
summary = extractSessionSummary(transcriptPath);
} else {
log(`[SessionEnd] Transcript not found: ${transcriptPath}`);
}
}
// Decide whether to call LLM for a richer summary.
// Triggers: context remaining < 20%, or every 50 user messages as a baseline.
let llmSummary = null;
if (transcriptPath && summary && fs.existsSync(transcriptPath)) {
const contextPct = getContextRemainingPct(transcriptPath);
const isContextLow = contextPct !== null && contextPct < getContextThreshold();
const interval = parseInt(process.env.ECC_LLM_SUMMARY_INTERVAL || '50', 10);
const safeInterval = Number.isFinite(interval) && interval > 0 ? interval : 50;
const isPeriodicTurn = summary.totalMessages > 0 && summary.totalMessages % safeInterval === 0;
if (isContextLow || isPeriodicTurn) {
log(`[SessionEnd] LLM summary triggered (context: ${contextPct ?? 'unknown'}%, messages: ${summary.totalMessages})`);
llmSummary = generateSessionSummary(transcriptPath);
if (llmSummary) {
log('[SessionEnd] LLM summary generated successfully');
} else {
log('[SessionEnd] LLM summary failed; falling back to mechanical extraction');
}
}
}
if (fs.existsSync(sessionFile)) {
const existing = readFile(sessionFile);
let updatedContent = existing;
if (existing) {
const merged = mergeSessionHeader(existing, today, currentTime, sessionMetadata);
if (merged) {
updatedContent = merged;
} else {
log(`[SessionEnd] Failed to normalize header in ${sessionFile}`);
}
}
// If we have a new summary, update only the generated summary block.
// This keeps repeated Stop invocations idempotent and preserves
// user-authored sections in the same session file.
if (summary && updatedContent) {
const summaryBlock = llmSummary ? `${SUMMARY_START_MARKER}\n${llmSummary}\n${SUMMARY_END_MARKER}` : buildSummaryBlock(summary);
// Use function replacers: summaryBlock embeds raw user-message text, and a
// string replacement argument interprets $-sequences ($&, $$, $`, $', $n).
// A $& in a user message would otherwise re-inject the entire matched block
// and corrupt the persisted summary. A function replacer is treated literally.
if (updatedContent.includes(SUMMARY_START_MARKER) && updatedContent.includes(SUMMARY_END_MARKER)) {
updatedContent = updatedContent.replace(new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`), () => summaryBlock);
} else {
// Migration path for files created before summary markers existed.
updatedContent = updatedContent.replace(
/## (?:Session Summary|Current State)[\s\S]*?$/,
() => `${summaryBlock}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`\n`
);
}
}
if (updatedContent) {
writeFile(sessionFile, updatedContent);
}
log(`[SessionEnd] Updated session file: ${sessionFile}`);
} else {
// Create new session file
const block = llmSummary ? `${SUMMARY_START_MARKER}\n${llmSummary}\n${SUMMARY_END_MARKER}` : summary ? buildSummaryBlock(summary) : null;
const summarySection = block
? `${block}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\``
: `## Current State\n\n[Session context goes here]\n\n### Completed\n- [ ]\n\n### In Progress\n- [ ]\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\``;
const template = `${buildSessionHeader(today, currentTime, sessionMetadata)}${SESSION_SEPARATOR}${summarySection}
`;
writeFile(sessionFile, template);
log(`[SessionEnd] Created session file: ${sessionFile}`);
}
process.exit(0);
}
function buildSummarySection(summary) {
let section = '## Session Summary\n\n';
// Tasks (from user messages — collapse newlines and escape backticks to prevent markdown breaks)
section += '### Tasks\n';
for (const msg of summary.userMessages) {
section += `- ${msg.replace(/\n/g, ' ').replace(/`/g, '\\`')}\n`;
}
section += '\n';
// Files modified
if (summary.filesModified.length > 0) {
section += '### Files Modified\n';
for (const f of summary.filesModified) {
section += `- ${f}\n`;
}
section += '\n';
}
// Tools used
if (summary.toolsUsed.length > 0) {
section += `### Tools Used\n${summary.toolsUsed.join(', ')}\n\n`;
}
section += `### Stats\n- Total user messages: ${summary.totalMessages}\n`;
return section;
}
function buildSummaryBlock(summary) {
return `${SUMMARY_START_MARKER}\n${buildSummarySection(summary).trim()}\n${SUMMARY_END_MARKER}`;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env node
'use strict';
/**
* session-start-bootstrap.js
*
* Bootstrap loader for the ECC SessionStart hook.
*
* Problem this solves: the previous approach embedded this logic as an inline
* `node -e "..."` string inside hooks.json. Characters like `!` (used in
* `!org.isDirectory()`) can trigger bash history expansion or other shell
* interpretation issues depending on the environment, causing
* "SessionStart:startup hook error" to appear in the Claude Code CLI header.
*
* By extracting to a standalone file, the shell never sees the JavaScript
* source and the `!` characters are safe. Behaviour is otherwise identical.
*
* How it works:
* 1. Reads the raw JSON event from stdin (passed by Claude Code).
* 2. Resolves the ECC plugin root directory (via CLAUDE_PLUGIN_ROOT env var
* or a set of well-known fallback paths).
* 3. Delegates to `scripts/hooks/run-with-flags.js` with the `session:start`
* event, which applies hook-profile gating and then runs session-start.js.
* 4. Passes stdout/stderr through and forwards the child exit code.
* 5. If the plugin root cannot be found, emits a warning and passes stdin
* through unchanged so Claude Code can continue normally.
*/
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { resolveEccRoot } = require('../lib/resolve-ecc-root');
// Read the raw JSON event from stdin
const raw = fs.readFileSync(0, 'utf8');
// Path (relative to plugin root) to the hook runner
const rel = path.join('scripts', 'hooks', 'run-with-flags.js');
// Resolve the ECC plugin root via the shared resolver, probing for the runner
// so a valid root is one that actually contains run-with-flags.js.
const root = resolveEccRoot({ probe: rel });
const script = path.join(root, rel);
if (fs.existsSync(script)) {
const result = spawnSync(
process.execPath,
[script, 'session:start', 'scripts/hooks/session-start.js', 'minimal,standard,strict'],
{
input: raw,
encoding: 'utf8',
env: process.env,
cwd: process.cwd(),
timeout: 30000,
}
);
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
if (stdout) {
process.stdout.write(stdout);
} else {
process.stdout.write(raw);
}
if (result.stderr) {
process.stderr.write(result.stderr);
}
if (result.error || result.status === null || result.signal) {
const reason = result.error
? result.error.message
: result.signal
? 'signal ' + result.signal
: 'missing exit status';
process.stderr.write('[SessionStart] ERROR: session-start hook failed: ' + reason + '\n');
process.exit(1);
}
process.exit(Number.isInteger(result.status) ? result.status : 0);
}
process.stderr.write(
'[SessionStart] WARNING: could not resolve ECC plugin root; skipping session-start hook\n'
);
process.stdout.write(raw);
+772
View File
@@ -0,0 +1,772 @@
#!/usr/bin/env node
/**
* SessionStart Hook - Load previous context on new session
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs when a new Claude session starts. Loads the most recent session
* summary into Claude's context via stdout, and reports available
* sessions and learned skills.
*/
const {
getSessionsDir,
getSessionSearchDirs,
getLearnedSkillsDir,
getProjectName,
findFiles,
ensureDir,
readFile,
stripAnsi,
log
} = require('../lib/utils');
const { resolveProjectContext, writeSessionLease, resolveSessionId, getHomunculusDir } = require('../lib/observer-sessions');
const { getPackageManager, getSelectionPrompt } = require('../lib/package-manager');
const { listAliases } = require('../lib/session-aliases');
const { detectProjectType } = require('../lib/project-detect');
const path = require('path');
const fs = require('fs');
const DEFAULT_INSTINCT_CONFIDENCE_THRESHOLD = 0.7;
const DEFAULT_MAX_INJECTED_INSTINCTS = 6;
const MAX_INJECTED_LEARNED_SKILLS = 6;
const MAX_LEARNED_SKILL_SUMMARY_CHARS = 220;
const DEFAULT_SESSION_START_CONTEXT_MAX_CHARS = 8000;
const DEFAULT_SESSION_RETENTION_DAYS = 30;
const SESSION_START_MODE_INVALID = 'invalid';
const SESSION_START_MODE_SKIP = 'skip';
/**
* Resolve a filesystem path to its canonical (real) form.
*
* Handles symlinks and, on case-insensitive filesystems (macOS, Windows),
* normalizes casing so that path comparisons are reliable.
* Falls back to the original path if resolution fails (e.g. path no longer exists).
*
* @param {string} p - The path to normalize.
* @returns {string} The canonical path, or the original if resolution fails.
*/
function normalizePath(p) {
try {
return fs.realpathSync(p);
} catch {
return p;
}
}
function dedupeRecentSessions(searchDirs) {
const recentSessionsByName = new Map();
for (const [dirIndex, dir] of searchDirs.entries()) {
const matches = findFiles(dir, '*-session.tmp', { maxAge: 7 });
for (const match of matches) {
const basename = path.basename(match.path);
const current = {
...match,
basename,
dirIndex,
};
const existing = recentSessionsByName.get(basename);
if (
!existing
|| current.mtime > existing.mtime
|| (current.mtime === existing.mtime && current.dirIndex < existing.dirIndex)
) {
recentSessionsByName.set(basename, current);
}
}
}
return Array.from(recentSessionsByName.values())
.sort((left, right) => right.mtime - left.mtime || left.dirIndex - right.dirIndex);
}
/**
* Resolve session retention days from the ECC_SESSION_RETENTION_DAYS env var.
*
* @returns {number|null} The retention window in days, or `null` when the
* user has explicitly opted out of pruning. Falsy/garbage values fall back
* to {@link DEFAULT_SESSION_RETENTION_DAYS}.
*/
function getSessionRetentionDays() {
const raw = process.env.ECC_SESSION_RETENTION_DAYS;
if (!raw) return DEFAULT_SESSION_RETENTION_DAYS;
const normalized = String(raw).trim().toLowerCase();
if (['0', 'off', 'false', 'disabled', 'never', 'none'].includes(normalized)) {
return null;
}
const parsed = Number.parseInt(raw, 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_SESSION_RETENTION_DAYS;
}
function isSessionStartContextDisabled() {
const raw = String(process.env.ECC_SESSION_START_CONTEXT || '').trim().toLowerCase();
return ['0', 'false', 'off', 'none', 'disabled'].includes(raw);
}
function getSessionStartMaxContextChars() {
const raw = process.env.ECC_SESSION_START_MAX_CHARS;
if (!raw) return DEFAULT_SESSION_START_CONTEXT_MAX_CHARS;
const parsed = Number.parseInt(raw, 10);
return Number.isInteger(parsed) && parsed >= 0 ? parsed : DEFAULT_SESSION_START_CONTEXT_MAX_CHARS;
}
/**
* Resolve the minimum confidence an instinct needs to be injected at
* SessionStart. Overridable via `ECC_INSTINCT_CONFIDENCE_THRESHOLD`
* (a number in [0, 1]); falsy or out-of-range values fall back to
* {@link DEFAULT_INSTINCT_CONFIDENCE_THRESHOLD}.
*
* @returns {number} The confidence floor for injected instincts.
*/
function getInstinctConfidenceThreshold() {
const raw = process.env.ECC_INSTINCT_CONFIDENCE_THRESHOLD;
if (!raw) return DEFAULT_INSTINCT_CONFIDENCE_THRESHOLD;
// Require a plain decimal (e.g. "0.7", "1", "0.95") so trailing junk
// ("0.7x") and non-decimal numeric syntax like "0x1" (hex) or "1e2"
// (exponent) are rejected whole rather than silently accepted by Number().
const normalized = raw.trim();
if (!/^\d+(\.\d+)?$/.test(normalized)) return DEFAULT_INSTINCT_CONFIDENCE_THRESHOLD;
const parsed = Number(normalized);
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1
? parsed
: DEFAULT_INSTINCT_CONFIDENCE_THRESHOLD;
}
/**
* Resolve the maximum number of instincts injected at SessionStart.
* Overridable via `ECC_MAX_INJECTED_INSTINCTS` (a positive integer);
* falsy or invalid values fall back to
* {@link DEFAULT_MAX_INJECTED_INSTINCTS}.
*
* @returns {number} The cap on injected instincts.
*/
function getMaxInjectedInstincts() {
const raw = process.env.ECC_MAX_INJECTED_INSTINCTS;
if (!raw) return DEFAULT_MAX_INJECTED_INSTINCTS;
// Require a plain non-negative integer so "3.9", "6abc", "0x1" (hex),
// and "1e2" (exponent) are rejected whole and fall back to the default,
// rather than parseInt truncating or Number() accepting alternate syntax.
const normalized = raw.trim();
if (!/^\d+$/.test(normalized)) return DEFAULT_MAX_INJECTED_INSTINCTS;
const parsed = Number(normalized);
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_INJECTED_INSTINCTS;
}
function getSessionStartMode(rawInput) {
const input = String(rawInput || '');
if (!input.trim()) return null;
let payload;
try {
payload = JSON.parse(input);
} catch {
log(`[SessionStart] Invalid stdin payload; skipping previous session summary injection. Length: ${input.length}`);
return SESSION_START_MODE_INVALID;
}
const supportedModes = new Set(['startup', 'resume', 'clear', 'compact']);
const hookName = typeof payload.hookName === 'string' ? payload.hookName.trim() : '';
if (hookName.startsWith('SessionStart:')) {
const mode = hookName.slice('SessionStart:'.length).trim().toLowerCase();
return supportedModes.has(mode) ? mode : SESSION_START_MODE_SKIP;
}
if (payload.hook_event_name === 'SessionStart') {
const mode = typeof payload.source === 'string' ? payload.source.trim().toLowerCase() : '';
return supportedModes.has(mode) ? mode : SESSION_START_MODE_SKIP;
}
return SESSION_START_MODE_SKIP;
}
function limitSessionStartContext(additionalContext, maxChars = getSessionStartMaxContextChars()) {
const context = String(additionalContext || '');
if (context.length <= maxChars) {
return context;
}
const marker = '\n\n[SessionStart truncated context. Set ECC_SESSION_START_MAX_CHARS to raise the cap or ECC_SESSION_START_CONTEXT=off to disable injected context.]';
const prefixLength = Math.max(0, maxChars - marker.length);
log(`[SessionStart] Truncated additional context from ${context.length} to ${maxChars} chars`);
return `${context.slice(0, prefixLength).trimEnd()}${marker}`.slice(0, maxChars);
}
function pruneExpiredSessions(searchDirs, retentionDays) {
const uniqueDirs = Array.from(new Set(searchDirs.filter(dir => typeof dir === 'string' && dir.length > 0)));
let removed = 0;
for (const dir of uniqueDirs) {
if (!fs.existsSync(dir)) continue;
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('-session.tmp')) continue;
const fullPath = path.join(dir, entry.name);
let stats;
try {
stats = fs.statSync(fullPath);
} catch {
continue;
}
const ageInDays = (Date.now() - stats.mtimeMs) / (1000 * 60 * 60 * 24);
if (ageInDays <= retentionDays) continue;
try {
fs.rmSync(fullPath, { force: true });
removed += 1;
} catch (error) {
log(`[SessionStart] Warning: failed to prune expired session ${fullPath}: ${error.message}`);
}
}
}
return removed;
}
/**
* Select the best matching session for the current working directory.
*
* Session files written by session-end.js contain header fields like:
* **Project:** my-project
* **Worktree:** /path/to/project
*
* This function reads each session file once, caching its content, and
* returns both the selected session object and its already-read content
* to avoid duplicate I/O in the caller.
*
* Priority (highest to lowest):
* 1. Exact worktree (cwd) match — most recent
* 2. Same project name match for legacy sessions without Worktree metadata
* 3. No injection when sessions belong to a different worktree/project
*
* Sessions are already sorted newest-first, so the first match in each
* category wins.
*
* @param {Array<Object>} sessions - Deduplicated session list, sorted newest-first.
* @param {string} cwd - Current working directory (process.cwd()).
* @param {string} currentProject - Current project name from getProjectName().
* @returns {{ session: Object, content: string, matchReason: string } | null}
* The best matching session with its cached content and match reason,
* or null if the sessions array is empty or all files are unreadable.
*/
function selectMatchingSession(sessions, cwd, currentProject) {
if (sessions.length === 0) return null;
// Normalize cwd once outside the loop to avoid repeated syscalls
const normalizedCwd = normalizePath(cwd);
let projectMatch = null;
let projectMatchContent = null;
let readableSessions = 0;
for (const session of sessions) {
const content = readFile(session.path);
if (!content) continue;
readableSessions++;
// Extract **Worktree:** field
const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.+)$/m);
const sessionWorktree = worktreeMatch ? worktreeMatch[1].trim() : '';
// Exact worktree match — best possible, return immediately
// Normalize both paths to handle symlinks and case-insensitive filesystems
if (sessionWorktree && normalizePath(sessionWorktree) === normalizedCwd) {
return { session, content, matchReason: 'worktree' };
}
// Project name match is only safe for legacy session files written before
// Worktree metadata existed. A different explicit Worktree is not a match.
if (!projectMatch && currentProject && !sessionWorktree) {
const projectFieldMatch = content.match(/\*\*Project:\*\*\s*(.+)$/m);
const sessionProject = projectFieldMatch ? projectFieldMatch[1].trim() : '';
if (sessionProject && sessionProject === currentProject) {
projectMatch = session;
projectMatchContent = content;
}
}
}
if (projectMatch) {
return { session: projectMatch, content: projectMatchContent, matchReason: 'project' };
}
log(readableSessions > 0
? '[SessionStart] No worktree/project session match found'
: '[SessionStart] All session files were unreadable');
return null;
}
function parseInstinctFile(content) {
const instincts = [];
let current = null;
let inFrontmatter = false;
let contentLines = [];
for (const line of String(content).split('\n')) {
if (line.trim() === '---') {
if (inFrontmatter) {
inFrontmatter = false;
} else {
if (current && current.id) {
current.content = contentLines.join('\n').trim();
instincts.push(current);
}
current = {};
contentLines = [];
inFrontmatter = true;
}
continue;
}
if (inFrontmatter) {
const separatorIndex = line.indexOf(':');
if (separatorIndex === -1) continue;
const key = line.slice(0, separatorIndex).trim();
let value = line.slice(separatorIndex + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (key === 'confidence') {
const parsed = Number.parseFloat(value);
current[key] = Number.isFinite(parsed) ? parsed : 0.5;
} else {
current[key] = value;
}
} else if (current) {
contentLines.push(line);
}
}
if (current && current.id) {
current.content = contentLines.join('\n').trim();
instincts.push(current);
}
return instincts;
}
function readInstinctsFromDir(directory, scope) {
if (!directory || !fs.existsSync(directory)) return [];
const entries = fs.readdirSync(directory, { withFileTypes: true })
.filter(entry => entry.isFile() && /\.(ya?ml|md)$/i.test(entry.name))
.sort((left, right) => left.name.localeCompare(right.name));
const instincts = [];
for (const entry of entries) {
const filePath = path.join(directory, entry.name);
try {
const parsed = parseInstinctFile(fs.readFileSync(filePath, 'utf8'));
for (const instinct of parsed) {
instincts.push({
...instinct,
_scopeLabel: scope,
_sourceFile: filePath,
});
}
} catch (error) {
log(`[SessionStart] Warning: failed to parse instinct file ${filePath}: ${error.message}`);
}
}
return instincts;
}
function extractInstinctAction(content) {
const actionMatch = String(content || '').match(/## Action\s*\n+([\s\S]+?)(?:\n## |\n---|$)/);
const actionBlock = (actionMatch ? actionMatch[1] : String(content || '')).trim();
const firstLine = actionBlock
.split('\n')
.map(line => line.trim())
.find(Boolean);
return firstLine || '';
}
function summarizeActiveInstincts(observerContext) {
const homunculusDir = getHomunculusDir();
const globalDirs = [
{ dir: path.join(homunculusDir, 'instincts', 'personal'), scope: 'global' },
{ dir: path.join(homunculusDir, 'instincts', 'inherited'), scope: 'global' },
];
const projectDirs = observerContext.isGlobal ? [] : [
{ dir: path.join(observerContext.projectDir, 'instincts', 'personal'), scope: 'project' },
{ dir: path.join(observerContext.projectDir, 'instincts', 'inherited'), scope: 'project' },
];
const scopedInstincts = [
...projectDirs.flatMap(({ dir, scope }) => readInstinctsFromDir(dir, scope)),
...globalDirs.flatMap(({ dir, scope }) => readInstinctsFromDir(dir, scope)),
];
const confidenceThreshold = getInstinctConfidenceThreshold();
const maxInjected = getMaxInjectedInstincts();
const deduped = new Map();
for (const instinct of scopedInstincts) {
if (!instinct.id || instinct.confidence < confidenceThreshold) continue;
const existing = deduped.get(instinct.id);
if (!existing || (existing._scopeLabel !== 'project' && instinct._scopeLabel === 'project')) {
deduped.set(instinct.id, instinct);
}
}
const ranked = Array.from(deduped.values())
.map(instinct => ({
...instinct,
action: extractInstinctAction(instinct.content),
}))
.filter(instinct => instinct.action)
.sort((left, right) => {
if (right.confidence !== left.confidence) return right.confidence - left.confidence;
if (left._scopeLabel !== right._scopeLabel) return left._scopeLabel === 'project' ? -1 : 1;
return String(left.id).localeCompare(String(right.id));
})
.slice(0, maxInjected);
if (ranked.length === 0) {
return '';
}
log(`[SessionStart] Injecting ${ranked.length} instinct(s) into session context`);
const lines = ranked.map(instinct => {
const scope = instinct._scopeLabel === 'project' ? 'project' : 'global';
const confidence = `${Math.round(instinct.confidence * 100)}%`;
return `- [${scope} ${confidence}] ${instinct.action}`;
});
return `Active instincts:\n${lines.join('\n')}`;
}
function stripMarkdownInline(value) {
return String(value || '')
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.trim();
}
function collapseWhitespace(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function truncateSummary(value, maxLength = MAX_LEARNED_SKILL_SUMMARY_CHARS) {
const normalized = collapseWhitespace(stripMarkdownInline(value));
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`;
}
function extractMarkdownHeading(content) {
const match = String(content || '').match(/^#\s+(.+)$/m);
return match ? stripMarkdownInline(match[1]) : '';
}
function extractSection(content, headingPattern) {
const source = String(content || '');
const match = source.match(new RegExp(`^##\\s+${headingPattern}\\s*\\n+([\\s\\S]+?)(?:\\n##\\s+|$)`, 'im'));
return match ? match[1].trim() : '';
}
function extractFirstParagraph(content) {
const withoutHeading = String(content || '').replace(/^#\s+.+$/m, '').trim();
return withoutHeading
.split(/\n\s*\n/)
.map(paragraph => paragraph.trim())
.find(Boolean) || '';
}
function summarizeLearnedSkillFile(filePath, learnedRoot) {
const content = readFile(filePath);
if (!content) return null;
const isDirectorySkill = path.basename(filePath).toLowerCase() === 'skill.md';
const slug = isDirectorySkill
? path.basename(path.dirname(filePath))
: path.basename(filePath, path.extname(filePath));
const title = extractMarkdownHeading(content) || slug;
const summary = truncateSummary(
extractSection(content, 'When to Use')
|| extractSection(content, 'Trigger')
|| extractSection(content, 'Problem')
|| extractFirstParagraph(content)
|| title
);
if (!summary) return null;
let mtime = 0;
try {
mtime = fs.statSync(filePath).mtimeMs;
} catch {
// Keep unreadable/deleted files out of recency priority without failing the hook.
}
const relativePath = path.relative(learnedRoot, filePath);
return {
slug,
title: truncateSummary(title, 80),
summary,
relativePath,
mtime,
};
}
function collectLearnedSkillFiles(learnedDir) {
const flatMarkdownFiles = findFiles(learnedDir, '*.md');
const directorySkillFiles = findFiles(learnedDir, 'SKILL.md', { recursive: true });
const byPath = new Map();
for (const match of [...flatMarkdownFiles, ...directorySkillFiles]) {
byPath.set(match.path, match);
}
return Array.from(byPath.values())
.sort((left, right) => right.mtime - left.mtime || left.path.localeCompare(right.path));
}
function summarizeLearnedSkills(learnedDir, learnedSkillFiles = collectLearnedSkillFiles(learnedDir)) {
const summaries = learnedSkillFiles
.map(match => summarizeLearnedSkillFile(match.path, learnedDir))
.filter(Boolean)
.slice(0, MAX_INJECTED_LEARNED_SKILLS);
if (summaries.length === 0) {
return '';
}
log(`[SessionStart] Injecting ${summaries.length} learned skill(s) into session context`);
const lines = summaries.map(skill => {
const titleSuffix = skill.title && skill.title !== skill.slug ? ` (${skill.title})` : '';
return `- ${skill.slug}${titleSuffix}: ${skill.summary}`;
});
return [
'Available learned skills:',
'Reference only; apply a learned skill only when it is relevant to the current user request.',
...lines,
].join('\n');
}
async function main() {
const sessionsDir = getSessionsDir();
const sessionSearchDirs = getSessionSearchDirs();
const learnedDir = getLearnedSkillsDir();
const additionalContextParts = [];
const observerContext = resolveProjectContext();
const maxContextChars = getSessionStartMaxContextChars();
const explicitContextDisabled = isSessionStartContextDisabled();
const shouldInjectContext = !explicitContextDisabled && maxContextChars !== 0;
const sessionStartMode = getSessionStartMode(fs.readFileSync(0, 'utf8'));
// Ensure directories exist
ensureDir(sessionsDir);
ensureDir(learnedDir);
const retentionDays = getSessionRetentionDays();
if (retentionDays === null) {
log('[SessionStart] Pruning disabled via ECC_SESSION_RETENTION_DAYS');
} else {
const prunedSessions = pruneExpiredSessions(sessionSearchDirs, retentionDays);
if (prunedSessions > 0) {
log(`[SessionStart] Pruned ${prunedSessions} expired session(s) older than ${retentionDays} day(s)`);
}
}
const observerSessionId = resolveSessionId();
if (observerSessionId) {
writeSessionLease(observerContext, observerSessionId, {
hook: 'SessionStart',
projectRoot: observerContext.projectRoot
});
log(`[SessionStart] Registered observer lease for ${observerSessionId}`);
} else {
log('[SessionStart] No CLAUDE_SESSION_ID available; skipping observer lease registration');
}
if (explicitContextDisabled) {
log('[SessionStart] Additional context injection disabled by ECC_SESSION_START_CONTEXT');
} else if (maxContextChars === 0) {
log('[SessionStart] Additional context injection disabled by ECC_SESSION_START_MAX_CHARS=0');
}
if (shouldInjectContext) {
const instinctSummary = summarizeActiveInstincts(observerContext);
if (instinctSummary) {
additionalContextParts.push(instinctSummary);
}
if (sessionStartMode && sessionStartMode !== 'startup') {
const reason = sessionStartMode === SESSION_START_MODE_INVALID
? 'invalid stdin payload'
: sessionStartMode === SESSION_START_MODE_SKIP
? 'unrecognized SessionStart payload'
: `non-startup SessionStart mode: ${sessionStartMode}`;
log(`[SessionStart] Skipping previous session summary injection for ${reason}`);
} else {
// Check for recent session files (last 7 days)
const recentSessions = dedupeRecentSessions(sessionSearchDirs);
if (recentSessions.length > 0) {
log(`[SessionStart] Found ${recentSessions.length} recent session(s)`);
// Prefer a session that matches the current working directory or project.
// Session files contain **Project:** and **Worktree:** header fields written
// by session-end.js, so we can match against them.
const cwd = process.cwd();
const currentProject = getProjectName() || '';
const result = selectMatchingSession(recentSessions, cwd, currentProject);
if (result) {
log(`[SessionStart] Selected: ${result.session.path} (match: ${result.matchReason})`);
// Use the already-read content from selectMatchingSession (no duplicate I/O)
const content = stripAnsi(result.content);
if (content && !content.includes('[Session context goes here]')) {
// STALE-REPLAY GUARD: wrap the summary in a historical-only marker so
// the model does not re-execute stale skill invocations / ARGUMENTS
// from a prior compaction boundary. Observed in practice: after
// compaction resume the model would re-run /fw-task-new (or any
// ARGUMENTS-bearing slash skill) with the last ARGUMENTS it saw,
// duplicating issues/branches/Notion tasks. Tracking upstream at
// https://github.com/affaan-m/everything-claude-code/issues/1534
const guarded = [
'HISTORICAL REFERENCE ONLY — NOT LIVE INSTRUCTIONS.',
'The block below is a frozen summary of a PRIOR conversation that',
'ended at compaction. Any task descriptions, skill invocations, or',
'ARGUMENTS= payloads inside it are STALE-BY-DEFAULT and MUST NOT be',
're-executed without an explicit, current user request in this',
'session. Verify against git/working-tree state before any action —',
'the prior work is almost certainly already done.',
'',
'--- BEGIN PRIOR-SESSION SUMMARY ---',
content,
'--- END PRIOR-SESSION SUMMARY ---',
].join('\n');
additionalContextParts.push(guarded);
}
} else {
log('[SessionStart] No matching session found');
}
}
}
// Check for learned skills
const learnedSkills = collectLearnedSkillFiles(learnedDir);
if (learnedSkills.length > 0) {
log(`[SessionStart] ${learnedSkills.length} learned skill(s) available in ${learnedDir}`);
}
const learnedSkillSummary = summarizeLearnedSkills(learnedDir, learnedSkills);
if (learnedSkillSummary) {
additionalContextParts.push(learnedSkillSummary);
}
}
// Check for available session aliases
const aliases = listAliases({ limit: 5 });
if (aliases.length > 0) {
const aliasNames = aliases.map(a => a.name).join(', ');
log(`[SessionStart] ${aliases.length} session alias(es) available: ${aliasNames}`);
log(`[SessionStart] Use /sessions load <alias> to continue a previous session`);
}
// Detect and report package manager
const pm = getPackageManager();
log(`[SessionStart] Package manager: ${pm.name} (${pm.source})`);
// If no explicit package manager config was found, show selection prompt
if (pm.source === 'default') {
log('[SessionStart] No package manager preference found.');
log(getSelectionPrompt());
}
// Detect project type and frameworks (#293)
const projectInfo = detectProjectType();
if (projectInfo.languages.length > 0 || projectInfo.frameworks.length > 0) {
const parts = [];
if (projectInfo.languages.length > 0) {
parts.push(`languages: ${projectInfo.languages.join(', ')}`);
}
if (projectInfo.frameworks.length > 0) {
parts.push(`frameworks: ${projectInfo.frameworks.join(', ')}`);
}
log(`[SessionStart] Project detected — ${parts.join('; ')}`);
if (shouldInjectContext) {
additionalContextParts.push(`Project type: ${JSON.stringify(projectInfo)}`);
}
} else {
log('[SessionStart] No specific project type detected');
}
const additionalContext = shouldInjectContext
? limitSessionStartContext(additionalContextParts.join('\n\n'), maxContextChars)
: '';
await writeSessionStartPayload(additionalContext);
}
function writeSessionStartPayload(additionalContext) {
return new Promise((resolve, reject) => {
let settled = false;
const payload = JSON.stringify({
hookSpecificOutput: {
hookEventName: 'SessionStart',
additionalContext
}
});
const handleError = (err) => {
if (settled) return;
settled = true;
if (err) {
log(`[SessionStart] stdout write error: ${err.message}`);
}
reject(err || new Error('stdout stream error'));
};
process.stdout.once('error', handleError);
process.stdout.write(payload, (err) => {
process.stdout.removeListener('error', handleError);
if (settled) return;
settled = true;
if (err) {
log(`[SessionStart] stdout write error: ${err.message}`);
reject(err);
return;
}
resolve();
});
});
}
main().catch(err => {
console.error('[SessionStart] Error:', err.message);
process.exitCode = 0; // Don't block on errors
});
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env node
/**
* Stop Hook: Batch format and typecheck all JS/TS files edited this response
*
* Cross-platform (Windows, macOS, Linux)
*
* Reads the accumulator written by post-edit-accumulator.js and processes all
* edited files in one pass: groups files by project root for a single formatter
* invocation per root, and groups .ts/.tsx files by tsconfig dir for a single
* tsc --noEmit per tsconfig. The accumulator is cleared on read so repeated
* Stop calls do not double-process files.
*
* Per-batch timeout is proportional to the number of batches so the total
* never exceeds the Stop hook budget (90 s reserved for overhead).
*/
'use strict';
const crypto = require('crypto');
const { execFileSync, spawnSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { findProjectRoot, detectFormatter, resolveFormatterBin } = require('../lib/resolve-formatter');
const MAX_STDIN = 1024 * 1024;
// Total ms budget reserved for all batches (leaves headroom below the 300s Stop timeout)
const TOTAL_BUDGET_MS = 270_000;
// Characters cmd.exe treats as separators/operators when shell: true is used.
// Includes spaces and parentheses to guard paths like "C:\Users\John Doe\...".
const UNSAFE_PATH_CHARS = /[&|<>^%!\s()]/;
/** Parse the accumulator text into a deduplicated array of file paths. */
function parseAccumulator(raw) {
return [...new Set(raw.split('\n').map(l => l.trim()).filter(Boolean))];
}
function getAccumFile() {
const raw =
process.env.CLAUDE_SESSION_ID ||
crypto.createHash('sha1').update(process.cwd()).digest('hex').slice(0, 12);
const sessionId = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
return path.join(os.tmpdir(), `ecc-edited-${sessionId}.txt`);
}
function formatBatch(projectRoot, files, timeoutMs) {
const formatter = detectFormatter(projectRoot);
if (!formatter) return;
const resolved = resolveFormatterBin(projectRoot, formatter);
if (!resolved) return;
const existingFiles = files.filter(f => fs.existsSync(f));
if (existingFiles.length === 0) return;
const fileArgs =
formatter === 'biome'
? [...resolved.prefix, 'check', '--write', ...existingFiles]
: [...resolved.prefix, '--write', ...existingFiles];
try {
if (process.platform === 'win32' && resolved.bin.endsWith('.cmd')) {
if (existingFiles.some(f => UNSAFE_PATH_CHARS.test(f))) {
process.stderr.write('[Hook] stop-format-typecheck: skipping batch — unsafe path chars\n');
return;
}
const result = spawnSync(resolved.bin, fileArgs, { cwd: projectRoot, shell: true, stdio: 'pipe', timeout: timeoutMs });
if (result.error) throw result.error;
} else {
execFileSync(resolved.bin, fileArgs, { cwd: projectRoot, stdio: ['pipe', 'pipe', 'pipe'], timeout: timeoutMs });
}
} catch {
// Formatter not installed or failed — non-blocking
}
}
function findTsConfigDir(filePath) {
let dir = path.dirname(filePath);
const fsRoot = path.parse(dir).root;
let depth = 0;
while (dir !== fsRoot && depth < 20) {
if (fs.existsSync(path.join(dir, 'tsconfig.json'))) return dir;
dir = path.dirname(dir);
depth++;
}
return null;
}
function typecheckBatch(tsConfigDir, editedFiles, timeoutMs) {
const isWin = process.platform === 'win32';
const npxBin = isWin ? 'npx.cmd' : 'npx';
const args = ['tsc', '--noEmit', '--pretty', 'false'];
const opts = { cwd: tsConfigDir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: timeoutMs };
let stdout = '';
let stderr = '';
let failed = false;
try {
if (isWin) {
// .cmd files require shell: true on Windows
const result = spawnSync(npxBin, args, { ...opts, shell: true });
if (result.error) return; // timed out or not found — non-blocking
if (result.status !== 0) {
stdout = result.stdout || '';
stderr = result.stderr || '';
failed = true;
}
} else {
execFileSync(npxBin, args, opts);
}
} catch (err) {
stdout = err.stdout || '';
stderr = err.stderr || '';
failed = true;
}
if (!failed) return;
const lines = (stdout + stderr).split('\n');
for (const filePath of editedFiles) {
const relPath = path.relative(tsConfigDir, filePath);
const candidates = new Set([filePath, relPath]);
const relevantLines = lines
.filter(line => { for (const c of candidates) { if (line.includes(c)) return true; } return false; })
.slice(0, 10);
if (relevantLines.length > 0) {
process.stderr.write(`[Hook] TypeScript errors in ${path.basename(filePath)}:\n`);
relevantLines.forEach(line => process.stderr.write(line + '\n'));
}
}
}
function main() {
const accumFile = getAccumFile();
let raw;
try {
raw = fs.readFileSync(accumFile, 'utf8');
} catch {
return; // No accumulator — nothing edited this response
}
try { fs.unlinkSync(accumFile); } catch { /* best-effort */ }
const files = parseAccumulator(raw);
if (files.length === 0) return;
const byProjectRoot = new Map();
for (const filePath of files) {
if (!/\.(ts|tsx|js|jsx)$/.test(filePath)) continue;
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) continue;
const root = findProjectRoot(path.dirname(resolved));
if (!byProjectRoot.has(root)) byProjectRoot.set(root, []);
byProjectRoot.get(root).push(resolved);
}
const byTsConfigDir = new Map();
for (const filePath of files) {
if (!/\.(ts|tsx)$/.test(filePath)) continue;
const resolved = path.resolve(filePath);
if (!fs.existsSync(resolved)) continue;
const tsDir = findTsConfigDir(resolved);
if (!tsDir) continue;
if (!byTsConfigDir.has(tsDir)) byTsConfigDir.set(tsDir, []);
byTsConfigDir.get(tsDir).push(resolved);
}
// Distribute the budget evenly across all batches so the cumulative total
// stays within the Stop hook wall-clock limit even in large monorepos.
const totalBatches = byProjectRoot.size + byTsConfigDir.size;
const perBatchMs = totalBatches > 0 ? Math.floor(TOTAL_BUDGET_MS / totalBatches) : 60_000;
for (const [root, batch] of byProjectRoot) formatBatch(root, batch, perBatchMs);
for (const [tsDir, batch] of byTsConfigDir) typecheckBatch(tsDir, batch, perBatchMs);
}
/**
* Exported so run-with-flags.js uses require() instead of spawnSync,
* letting the 300s hooks.json timeout govern the full batch.
*
* @param {string} rawInput - Raw JSON string from stdin (Stop event payload)
* @returns {string} The original input (pass-through)
*/
function run(rawInput) {
try {
main();
} catch (err) {
process.stderr.write(`[Hook] stop-format-typecheck error: ${err.message}\n`);
}
return rawInput;
}
if (require.main === module) {
let stdinData = '';
let truncated = false;
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (stdinData.length < MAX_STDIN) {
const remaining = MAX_STDIN - stdinData.length;
stdinData += chunk.substring(0, remaining);
if (chunk.length > remaining) truncated = true;
} else {
truncated = true;
}
});
process.stdin.on('end', () => {
const output = run(stdinData);
// Never echo truncated stdin (invalid JSON would be reported as a Stop
// hook failure, #2090); flush stdout before exiting so large payloads
// are not cut at the pipe buffer.
if (truncated) {
process.stderr.write('[Hook] stop-format-typecheck: stdin exceeded 1MB; suppressing pass-through (fail-open)\n');
process.exit(0);
}
if (!output) {
process.exit(0);
}
process.stdout.write(output, () => process.exit(0));
});
}
module.exports = { run, parseAccumulator };
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env node
/**
* Strategic Compact Suggester
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs on PreToolUse or periodically to suggest manual compaction at logical intervals
*
* Why manual over auto-compact:
* - Auto-compact happens at arbitrary points, often mid-task
* - Strategic compacting preserves context through logical phases
* - Compact after exploration, before execution
* - Compact after completing a milestone, before starting next
*
* Two signals (#2155):
* - Tool-call count: first at COMPACT_THRESHOLD (default 50), then every 25.
* - Context size (primary): the latest assistant `usage` record from the
* session transcript, compared against a window-scaled token threshold
* (COMPACT_CONTEXT_THRESHOLD; default 160k on a 200k window, 250k on 1M),
* re-reminding after every COMPACT_CONTEXT_INTERVAL tokens of growth
* (default 60k). Tool count is a weak proxy for window pressure — a few
* large reads can fill the window in very few calls, and many tiny calls
* can cross 50 while the window is barely used.
*/
const fs = require('fs');
const path = require('path');
const {
getTempDir,
writeFile,
readStdinJson,
log,
output
} = require('../lib/utils');
const {
readLatestContextTokens,
resolveContextWindowTokens,
resolveContextThreshold,
resolveContextInterval,
computeContextBucket,
formatWindowLabel
} = require('../lib/transcript-context');
const COUNTER_FILE_PREFIX = 'claude-tool-count-';
const CONTEXT_BUCKET_FILE_PREFIX = 'claude-context-bucket-';
const STATE_FILE_PREFIXES = [COUNTER_FILE_PREFIX, CONTEXT_BUCKET_FILE_PREFIX];
const DEFAULT_COMPACT_STATE_TTL_DAYS = 14;
function getCounterRetentionDays() {
const raw = process.env.COMPACT_STATE_TTL_DAYS;
if (!raw) return DEFAULT_COMPACT_STATE_TTL_DAYS;
const parsed = Number.parseInt(raw, 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_COMPACT_STATE_TTL_DAYS;
}
/**
* Sweep stale per-session state files from the temp dir.
*
* Each session writes `claude-tool-count-<sessionId>` (and, with the context
* signal, `claude-context-bucket-<sessionId>`) into the OS temp dir; nothing
* else removes them. Without a sweep these files accumulate one-per-session
* forever. This helper removes state files whose mtime is older than
* `retentionDays`, while preserving the active session's files (which are
* about to be re-written by the caller).
*
* The helper never throws; per the always-exit-0 hook contract any
* filesystem failure is swallowed and logged to stderr.
*
* @param {string} tempDir - The temp directory to sweep.
* @param {number} retentionDays - Files older than this many days are removed.
* @param {string[]} currentStateFiles - Absolute paths of the active session's
* state files; preserved unconditionally.
*/
function cleanupOldCounters(tempDir, retentionDays, currentStateFiles) {
let entries;
try {
entries = fs.readdirSync(tempDir, { withFileTypes: true });
} catch (err) {
log(`[StrategicCompact] Skipping counter sweep; readdir failed: ${err.message}`);
return;
}
const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
const currentBasenames = new Set(currentStateFiles.map(filePath => path.basename(filePath)));
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!STATE_FILE_PREFIXES.some(prefix => entry.name.startsWith(prefix))) continue;
if (currentBasenames.has(entry.name)) continue;
const fullPath = path.join(tempDir, entry.name);
let stats;
try {
stats = fs.statSync(fullPath);
} catch {
continue;
}
// Strict "older than" semantics per the docstring: a file whose mtime
// sits exactly on the cutoff boundary has age == retentionDays, which
// is not *older than* retentionDays, so preserve it. Use >= so only
// strictly older files (mtimeMs < cutoffMs) fall through to deletion.
if (stats.mtimeMs >= cutoffMs) continue;
try {
fs.rmSync(fullPath, { force: true });
} catch (err) {
log(`[StrategicCompact] Warning: failed to prune stale counter ${fullPath}: ${err.message}`);
}
}
}
/**
* Increment and persist the per-session tool-call counter.
* Uses fd-based read+write to reduce (but not eliminate) the race window
* between concurrent hook invocations.
*/
function incrementToolCallCount(counterFile) {
let count = 1;
try {
const fd = fs.openSync(counterFile, 'a+');
try {
const buf = Buffer.alloc(64);
const bytesRead = fs.readSync(fd, buf, 0, 64, 0);
if (bytesRead > 0) {
const parsed = parseInt(buf.toString('utf8', 0, bytesRead).trim(), 10);
// Clamp to reasonable range — corrupted files could contain huge values
// that pass Number.isFinite() (e.g., parseInt('9'.repeat(30)) => 1e+29)
count = (Number.isFinite(parsed) && parsed > 0 && parsed <= 1000000)
? parsed + 1
: 1;
}
// Truncate and write new value
fs.ftruncateSync(fd, 0);
fs.writeSync(fd, String(count), 0);
} finally {
fs.closeSync(fd);
}
} catch {
// Fallback: just use writeFile if fd operations fail
writeFile(counterFile, String(count));
}
return count;
}
/**
* Read the last context bucket this session already fired for (-1 when the
* suggestion has not fired yet or the state file is unreadable/corrupted).
*/
function readLastContextBucket(bucketFile) {
try {
const parsed = parseInt(fs.readFileSync(bucketFile, 'utf8').trim(), 10);
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 1000000 ? parsed : -1;
} catch {
return -1;
}
}
/**
* Build the context-size suggestion when the transcript shows the session has
* crossed into a new context bucket. Returns null when the signal is silent
* (no transcript, below threshold, disabled, or already fired for the bucket).
*
* Never throws — any transcript or state-file failure silently disables the
* signal so the hook keeps its always-exit-0 contract.
*/
function buildContextSuggestion(transcriptPath, bucketFile, env) {
try {
const usage = readLatestContextTokens(transcriptPath);
if (!usage) return null;
const windowTokens = resolveContextWindowTokens(usage.tokens, usage.model);
const threshold = resolveContextThreshold(env, windowTokens);
if (threshold <= 0) return null; // COMPACT_CONTEXT_THRESHOLD=0 disables
const interval = resolveContextInterval(env);
const bucket = computeContextBucket(usage.tokens, threshold, interval);
if (bucket < 0) return null;
const lastBucket = readLastContextBucket(bucketFile);
if (bucket <= lastBucket) return null;
writeFile(bucketFile, String(bucket));
const approxTokens = `${Math.round(usage.tokens / 1000)}k`;
const percent = Math.round((usage.tokens / windowTokens) * 100);
return `[StrategicCompact] Context ~${approxTokens} tokens (${percent}% of ${formatWindowLabel(windowTokens)} window) - consider /compact at the next logical boundary`;
} catch (err) {
log(`[StrategicCompact] Context signal skipped: ${err.message}`);
return null;
}
}
async function main() {
// Claude Code passes hook input via stdin JSON; session_id is the
// canonical field (legacy env var, then 'default', as fallbacks) and
// transcript_path points at the session transcript JSONL used by the
// context-size signal.
let input = {};
try {
input = await readStdinJson({ timeoutMs: 1000 });
} catch {
input = {};
}
const rawSessionId = (input && typeof input.session_id === 'string' && input.session_id)
? input.session_id
: (process.env.CLAUDE_SESSION_ID || 'default');
const sessionId = rawSessionId.replace(/[^a-zA-Z0-9_-]/g, '') || 'default';
const transcriptPath = (input && typeof input.transcript_path === 'string') ? input.transcript_path : '';
const tempDir = getTempDir();
const counterFile = path.join(tempDir, `${COUNTER_FILE_PREFIX}${sessionId}`);
const bucketFile = path.join(tempDir, `${CONTEXT_BUCKET_FILE_PREFIX}${sessionId}`);
// Sweep stale state files (concern 1 of #2156). Cheap, swallows errors,
// skips the active session's files. See cleanupOldCounters for details.
cleanupOldCounters(tempDir, getCounterRetentionDays(), [counterFile, bucketFile]);
const rawThreshold = parseInt(process.env.COMPACT_THRESHOLD || '50', 10);
const threshold = Number.isFinite(rawThreshold) && rawThreshold > 0 && rawThreshold <= 10000
? rawThreshold
: 50;
const count = incrementToolCallCount(counterFile);
const messages = [];
// Primary signal (#2155): real context size from the transcript's latest
// usage record. Fires at a window-scaled token threshold and re-fires only
// after the context grows by another interval step.
const contextSuggestion = buildContextSuggestion(transcriptPath, bucketFile, process.env);
if (contextSuggestion) {
messages.push(contextSuggestion);
}
// Secondary signal: tool-call count at threshold, then every 25 calls.
if (count === threshold) {
messages.push(`[StrategicCompact] ${threshold} tool calls reached - consider /compact if transitioning phases`);
} else if (count > threshold && (count - threshold) % 25 === 0) {
messages.push(`[StrategicCompact] ${count} tool calls - good checkpoint for /compact if context is stale`);
}
// log() writes to stderr (debug log). Per the Claude Code hooks guide,
// non-blocking PreToolUse stderr (exit 0) is only written to the debug log;
// it does not reach the model. To inject a user-facing suggestion without
// blocking the tool call, emit structured JSON to stdout with
// hookSpecificOutput.additionalContext — the documented mechanism for
// PreToolUse hooks to add context to the next model turn. Hooks must emit
// at most one stdout JSON payload per run, so both signals share it.
if (messages.length > 0) {
for (const msg of messages) {
log(msg);
}
output({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
additionalContext: messages.join('\n')
}
});
}
process.exit(0);
}
main().catch(err => {
console.error('[StrategicCompact] Error:', err.message);
process.exit(0);
});