merge: preserve tested commit-review source 2974

This commit is contained in:
haelyra
2026-10-09 16:10:04 -04:00
26 changed files with 1294 additions and 192 deletions
+5 -9
View File
@@ -5,7 +5,7 @@
{
"command": "node .cursor/hooks/session-start.js",
"event": "sessionStart",
"description": "Load previous context and detect environment"
"description": "Set the Cursor data root, load previous context, and detect the environment"
}
],
"sessionEnd": [
@@ -16,15 +16,10 @@
}
],
"beforeShellExecution": [
{
"command": "node .cursor/hooks/before-shell-execution-block-no-verify.js",
"event": "beforeShellExecution",
"description": "Block git hook-bypass flag to protect pre-commit, commit-msg, and pre-push hooks from being skipped"
},
{
"command": "node .cursor/hooks/before-shell-execution.js",
"event": "beforeShellExecution",
"description": "Tmux dev server blocker, tmux reminder, git push review"
"description": "Dispatch git hook-bypass protection, dev-server blocking, tmux reminders, and git push review"
}
],
"afterShellExecution": [
@@ -38,7 +33,7 @@
{
"command": "node .cursor/hooks/after-file-edit.js",
"event": "afterFileEdit",
"description": "Auto-format, TypeScript check, console.log warning, and frontend design-quality reminder"
"description": "Accumulate JS/TS edits, warn on console.log, and run the frontend design-quality reminder"
}
],
"beforeMCPExecution": [
@@ -108,7 +103,8 @@
{
"command": "node .cursor/hooks/stop.js",
"event": "stop",
"description": "Console.log audit on all modified files"
"description": "Batch format and typecheck edits, audit console.log usage, and persist session state",
"timeout": 300
}
]
}
+200 -30
View File
@@ -5,31 +5,113 @@
* then delegates to existing scripts/hooks/*.js
*/
const { execFileSync } = require('child_process');
const { spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const MAX_STDIN = 1024 * 1024;
const MAX_STDIN_BYTES = 1024 * 1024;
const STOP_FORMAT_TYPECHECK_BUDGET_MS = 210000;
const STOP_FORMAT_TYPECHECK_TIMEOUT_HEADROOM_MS = 15000;
function readStdin() {
function readStdin(options = {}) {
return new Promise((resolve) => {
const stream = options.stream || process.stdin;
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (data.length < MAX_STDIN) data += chunk.substring(0, MAX_STDIN - data.length);
let dataBytes = 0;
let truncated = false;
stream.setEncoding('utf8');
stream.on('data', chunk => {
const chunkBytes = Buffer.byteLength(chunk, 'utf8');
if (truncated || dataBytes + chunkBytes > MAX_STDIN_BYTES) {
truncated = true;
return;
}
data += chunk;
dataBytes += chunkBytes;
});
stream.on('end', () => {
if (truncated) {
if (options.includeMetadata !== true) {
process.stderr.write(
`[Cursor Hook] stdin exceeded ${MAX_STDIN_BYTES} bytes; suppressing truncated input\n`
);
}
resolve(options.includeMetadata === true
? { raw: '', truncated: true, readError: false }
: '');
return;
}
resolve(options.includeMetadata === true
? { raw: data, truncated: false, readError: false }
: data);
});
stream.on('error', error => {
process.stderr.write(`[Cursor Hook] stdin read failed: ${error.message}\n`);
resolve(options.includeMetadata === true
? { raw: '', truncated: false, readError: true }
: '');
});
process.stdin.on('end', () => resolve(data));
});
}
function getPluginRoot() {
return path.resolve(__dirname, '..', '..');
const candidates = [
// Installed layout: <project>/.cursor/hooks -> <project>/.cursor/scripts.
path.resolve(__dirname, '..'),
// Repository layout: <repo>/.cursor/hooks -> <repo>/scripts.
path.resolve(__dirname, '..', '..'),
];
return candidates.find(candidate => (
fs.existsSync(path.join(candidate, 'scripts', 'hooks'))
&& fs.existsSync(path.join(candidate, 'scripts', 'lib'))
)) || candidates[0];
}
function resolveRuntimePath(...segments) {
const runtimeRoot = getPluginRoot();
const resolvedRoot = path.resolve(runtimeRoot);
const resolvedPath = path.resolve(runtimeRoot, ...segments);
if (!resolvedPath.startsWith(`${resolvedRoot}${path.sep}`)) {
throw new Error(`Cursor hook runtime path escapes its root: ${resolvedPath}`);
}
if (!fs.existsSync(resolvedPath)) {
throw new Error(`Cursor hook runtime file not found: ${resolvedPath}`);
}
return resolvedPath;
}
function loadRuntimeModule(...segments) {
return require(resolveRuntimePath(...segments));
}
function fallbackHookEnabled(hookId, allowedProfiles) {
const enabled = String(process.env.ECC_HOOKS_ENABLED ?? 'true').trim().toLowerCase();
if (['0', 'false', 'no', 'off'].includes(enabled)) {
return false;
}
const rawProfile = String(process.env.ECC_HOOK_PROFILE || 'standard').trim().toLowerCase();
const profile = ['minimal', 'standard', 'strict'].includes(rawProfile)
? rawProfile
: 'standard';
const disabled = new Set(
String(process.env.ECC_DISABLED_HOOKS || '')
.split(',')
.map(value => value.trim().toLowerCase())
.filter(Boolean)
);
return !disabled.has(String(hookId || '').trim().toLowerCase())
&& allowedProfiles.includes(profile);
}
function transformToClaude(cursorInput, overrides = {}) {
const cursorSessionId = cursorInput.conversation_id || cursorInput.session_id || '';
return {
tool_input: {
command: cursorInput.command || cursorInput.args?.command || '',
file_path: cursorInput.path || cursorInput.file || cursorInput.args?.filePath || '',
file_path: cursorInput.file_path || cursorInput.path || cursorInput.file || cursorInput.args?.filePath || '',
...overrides.tool_input,
},
tool_output: {
@@ -37,6 +119,7 @@ function transformToClaude(cursorInput, overrides = {}) {
...overrides.tool_output,
},
transcript_path: cursorInput.transcript_path || cursorInput.transcriptPath || cursorInput.session?.transcript_path || '',
session_id: cursorSessionId,
_cursor: {
conversation_id: cursorInput.conversation_id,
hook_event_name: cursorInput.hook_event_name,
@@ -46,36 +129,123 @@ function transformToClaude(cursorInput, overrides = {}) {
};
}
function runExistingHook(scriptName, stdinData) {
const scriptPath = path.join(getPluginRoot(), 'scripts', 'hooks', scriptName);
function normalizeSessionStartOutput(rawOutput, options = {}) {
if (!rawOutput) return {};
const parsed = JSON.parse(rawOutput);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return {
...(parsed.env && typeof parsed.env === 'object' && !Array.isArray(parsed.env)
? { env: parsed.env }
: {}),
...(typeof parsed.additional_context === 'string' && parsed.additional_context
? { additional_context: parsed.additional_context }
: {}),
...(options.shared === true
&& typeof parsed.hookSpecificOutput?.additionalContext === 'string'
&& parsed.hookSpecificOutput.additionalContext
? { additional_context: parsed.hookSpecificOutput.additionalContext }
: {}),
};
}
function mergeSessionStartOutputs(outputs) {
const validOutputs = outputs.filter(output => output && typeof output === 'object');
const env = Object.assign({}, ...validOutputs.map(output => output.env || {}));
const additionalContext = validOutputs
.map(output => output.additional_context)
.filter(Boolean)
.join('\n\n');
return {
...(Object.keys(env).length > 0 ? { env } : {}),
...(additionalContext ? { additional_context: additionalContext } : {}),
};
}
function createStopFormatTypecheckOptions(sessionEnv = {}) {
return {
timeout: STOP_FORMAT_TYPECHECK_BUDGET_MS + STOP_FORMAT_TYPECHECK_TIMEOUT_HEADROOM_MS,
forwardStderr: true,
env: {
...sessionEnv,
ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS: String(STOP_FORMAT_TYPECHECK_BUDGET_MS),
},
};
}
function runExistingHook(scriptName, stdinData, options = {}) {
try {
execFileSync('node', [scriptPath], {
const scriptPath = resolveRuntimePath('scripts', 'hooks', scriptName);
let cursorSessionId = '';
try {
const input = typeof stdinData === 'string' ? JSON.parse(stdinData) : stdinData;
cursorSessionId = input?._cursor?.conversation_id
|| input?.conversation_id
|| input?.session_id
|| '';
} catch {
// Raw non-JSON hook input has no session identifier to propagate.
}
const result = spawnSync(process.execPath, [scriptPath], {
input: typeof stdinData === 'string' ? stdinData : JSON.stringify(stdinData),
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 15000,
encoding: 'utf8',
env: {
...process.env,
...(options.env || {}),
...(cursorSessionId
? { CLAUDE_SESSION_ID: String(cursorSessionId) }
: {}),
},
timeout: options.timeout || 15000,
cwd: process.cwd(),
});
} catch (e) {
if (e.status === 2) process.exit(2); // Forward blocking exit code
if (result.stderr && (result.status === 2 || options.forwardStderr === true)) {
process.stderr.write(result.stderr);
}
if (result.status === 2) {
process.exit(2);
}
if (result.error || result.signal || result.status === null) {
const reason = result.error
? result.error.message
: result.signal
? `signal ${result.signal}`
: 'missing exit status';
process.stderr.write(`[Cursor Hook] ${scriptName} failed: ${reason}\n`);
} else if (Number.isInteger(result.status) && result.status !== 0) {
process.stderr.write(
`[Cursor Hook] ${scriptName} exited with code ${result.status}; continuing\n`
);
}
return result;
} catch (error) {
process.stderr.write(`[Cursor Hook] ${scriptName} skipped: ${error.message}\n`);
return null;
}
}
function hookEnabled(hookId, allowedProfiles = ['standard', 'strict']) {
const rawProfile = String(process.env.ECC_HOOK_PROFILE || 'standard').toLowerCase();
const profile = ['minimal', 'standard', 'strict'].includes(rawProfile) ? rawProfile : 'standard';
const disabled = new Set(
String(process.env.ECC_DISABLED_HOOKS || '')
.split(',')
.map(v => v.trim().toLowerCase())
.filter(Boolean)
);
if (disabled.has(String(hookId || '').toLowerCase())) {
return false;
try {
const { isHookEnabled } = loadRuntimeModule('scripts', 'lib', 'hook-flags.js');
return isHookEnabled(hookId, { profiles: allowedProfiles });
} catch {
// Keep native Cursor checks controllable in partial or manually copied installs.
return fallbackHookEnabled(hookId, allowedProfiles);
}
return allowedProfiles.includes(profile);
}
module.exports = { readStdin, getPluginRoot, transformToClaude, runExistingHook, hookEnabled };
module.exports = {
readStdin,
getPluginRoot,
resolveRuntimePath,
loadRuntimeModule,
fallbackHookEnabled,
transformToClaude,
normalizeSessionStartOutput,
mergeSessionStartOutputs,
createStopFormatTypecheckOptions,
runExistingHook,
hookEnabled,
};
+13 -7
View File
@@ -4,16 +4,22 @@ readStdin().then(raw => {
try {
const input = JSON.parse(raw);
const claudeInput = transformToClaude(input, {
tool_input: { file_path: input.path || input.file || '' }
tool_input: { file_path: input.file_path || input.path || input.file || '' }
});
const claudeStr = JSON.stringify(claudeInput);
// Accumulate edited paths for batch format+typecheck at stop time
runExistingHook('post-edit-accumulator.js', claudeStr);
runExistingHook('post-edit-console-warn.js', claudeStr);
if (hookEnabled('post:edit:design-quality-check', ['standard', 'strict'])) {
runExistingHook('design-quality-check.js', claudeStr);
// Accumulate edited paths for batch format+typecheck at stop time.
if (hookEnabled('post:edit:accumulator', ['standard', 'strict'])) {
runExistingHook('post-edit-accumulator.js', claudeStr);
}
} catch {}
if (hookEnabled('post:edit:console-warn', ['standard', 'strict'])) {
runExistingHook('post-edit-console-warn.js', claudeStr, { forwardStderr: true });
}
if (hookEnabled('post:edit:design-quality-check', ['standard', 'strict'])) {
runExistingHook('design-quality-check.js', claudeStr, { forwardStderr: true });
}
} catch {
// Cursor post-event hooks fail open on malformed or partial payloads.
}
process.stdout.write(raw);
}).catch(() => process.exit(0));
+8 -2
View File
@@ -1,12 +1,18 @@
#!/usr/bin/env node
const { readStdin } = require('./adapter');
const { readStdin, hookEnabled } = require('./adapter');
readStdin().then(raw => {
if (!hookEnabled('post:mcp:audit', ['standard', 'strict'])) {
process.stdout.write(raw);
return;
}
try {
const input = JSON.parse(raw);
const server = input.server || input.mcp_server || 'unknown';
const tool = input.tool || input.mcp_tool || 'unknown';
const success = input.error ? 'FAILED' : 'OK';
console.error(`[ECC] MCP result: ${server}/${tool} - ${success}`);
} catch {}
} catch {
// Audit-only hook: malformed payloads must not interrupt the agent loop.
}
process.stdout.write(raw);
}).catch(() => process.exit(0));
+9 -3
View File
@@ -1,12 +1,18 @@
#!/usr/bin/env node
const { readStdin, runExistingHook, transformToClaude } = require('./adapter');
const { readStdin, runExistingHook, transformToClaude, hookEnabled } = require('./adapter');
readStdin().then(raw => {
if (!hookEnabled('post:tab-edit:format', ['standard', 'strict'])) {
process.stdout.write(raw);
return;
}
try {
const input = JSON.parse(raw);
const claudeInput = transformToClaude(input, {
tool_input: { file_path: input.path || input.file || '' }
tool_input: { file_path: input.file_path || input.path || input.file || '' }
});
runExistingHook('post-edit-format.js', JSON.stringify(claudeInput));
} catch {}
} catch {
// Formatting is advisory, so malformed payloads fail open.
}
process.stdout.write(raw);
}).catch(() => process.exit(0));
+8 -2
View File
@@ -1,11 +1,17 @@
#!/usr/bin/env node
const { readStdin } = require('./adapter');
const { readStdin, hookEnabled } = require('./adapter');
readStdin().then(raw => {
if (!hookEnabled('pre:mcp:audit', ['standard', 'strict'])) {
process.stdout.write(raw);
return;
}
try {
const input = JSON.parse(raw);
const server = input.server || input.mcp_server || 'unknown';
const tool = input.tool || input.mcp_tool || 'unknown';
console.error(`[ECC] MCP invocation: ${server}/${tool}`);
} catch {}
} catch {
// Audit-only hook: malformed payloads must not interrupt the agent loop.
}
process.stdout.write(raw);
}).catch(() => process.exit(0));
+10 -3
View File
@@ -1,13 +1,20 @@
#!/usr/bin/env node
const { readStdin } = require('./adapter');
const { readStdin, hookEnabled } = require('./adapter');
readStdin().then(raw => {
if (!hookEnabled('pre:read:sensitive-file-warning', ['minimal', 'standard', 'strict'])) {
process.stdout.write(raw);
return;
}
try {
const input = JSON.parse(raw);
const filePath = input.path || input.file || '';
const filePath = [input.file_path, input.path, input.file]
.find(value => typeof value === 'string' && value.trim()) || '';
if (/\.(env|key|pem)$|\.env\.|credentials|secret/i.test(filePath)) {
console.error('[ECC] WARNING: Reading sensitive file: ' + filePath);
console.error('[ECC] Ensure this data is not exposed in outputs');
}
} catch {}
} catch {
// Preserve the existing fail-open behavior for malformed Cursor payloads.
}
process.stdout.write(raw);
}).catch(() => process.exit(0));
@@ -1,63 +0,0 @@
#!/usr/bin/env node
/**
* Cursor wrapper for block-no-verify.
*
* Cursor hooks previously called `npx block-no-verify@1.1.2`, an external
* package whose matcher over-matches: it blocks legitimate `git commit`
* whenever the literal string `--no-verify` (or `no-verify`) appears
* anywhere in the command string, including inside the commit message
* body. See issue #2107.
*
* The Claude Code surface already routes through the local, in-repo hook
* `scripts/hooks/block-no-verify.js`, which performs flag-position-aware
* tokenisation (skipping the value of `-m`, `-F`, `-am "..."`, etc.) and
* passes 25 regression tests covering every false-positive case.
*
* This wrapper gives Cursor the same matcher: read Cursor stdin, transform
* to the Claude Code `tool_input.command` shape the local hook understands,
* delegate to its exported `run()`, then forward the exit code and stderr.
*/
'use strict';
const { readStdin, hookEnabled } = require('./adapter');
const { run } = require('../../scripts/hooks/block-no-verify');
readStdin()
.then(raw => {
if (!hookEnabled('pre:bash:block-no-verify', ['minimal', 'standard', 'strict'])) {
process.stdout.write(raw);
return;
}
let command = '';
try {
const parsed = JSON.parse(raw || '{}');
command = String(parsed.command || parsed.args?.command || '');
} catch {
command = String(raw || '');
}
// Local hook accepts either the raw command string or a Claude-Code
// shaped `{ tool_input: { command } }` JSON. Pass the Claude shape so
// the JSON branch in extractCommand() is exercised the same way the
// Claude Code surface exercises it — keeps the two surfaces on the
// same code path.
const claudeInput = JSON.stringify({ tool_input: { command } });
const result = run(claudeInput);
if (result && result.exitCode === 2) {
if (result.stderr) {
process.stderr.write(String(result.stderr) + '\n');
}
process.exit(2);
}
process.stdout.write(raw);
})
.catch(() => {
// Per repo rule: hooks must exit 0 on non-critical errors and never
// unexpectedly block tool execution. A parse / transport error here
// is non-critical — fall through.
process.exit(0);
});
+48 -15
View File
@@ -1,23 +1,56 @@
#!/usr/bin/env node
const { readStdin, hookEnabled } = require('./adapter');
const { splitShellSegments } = require('../../scripts/lib/shell-split');
const {
readStdin,
hookEnabled,
runExistingHook,
transformToClaude,
} = require('./adapter');
readStdin({ includeMetadata: true })
.then(({ raw, truncated, readError }) => {
if (truncated || readError) {
const hasBlockingGuard = (
hookEnabled('pre:bash:block-no-verify', ['minimal', 'standard', 'strict'])
|| (
process.platform !== 'win32'
&& hookEnabled('pre:bash:dev-server-block', ['standard', 'strict'])
)
);
if (hasBlockingGuard) {
console.error(truncated
? '[Cursor Hook] stdin exceeded the safety limit; blocking beforeShellExecution'
: '[Cursor Hook] stdin read failed; blocking beforeShellExecution');
process.exit(2);
}
console.error(truncated
? '[Cursor Hook] stdin exceeded the safety limit; suppressing truncated input'
: '[Cursor Hook] stdin read failed; suppressing untrusted input');
return;
}
readStdin()
.then(raw => {
try {
const input = JSON.parse(raw || '{}');
const cmd = String(input.command || input.args?.command || '');
let input;
let cmd;
try {
input = JSON.parse(raw || '{}');
cmd = String(input.command || input.args?.command || '');
} catch {
input = {};
cmd = String(raw || '');
}
const claudeInput = transformToClaude(input, {
tool_input: { command: cmd },
});
const claudeRaw = JSON.stringify(claudeInput);
// Cursor may execute only the first hook registered for an event. Keep
// every blocking shell guard behind this single event-level dispatcher.
if (hookEnabled('pre:bash:block-no-verify', ['minimal', 'standard', 'strict'])) {
runExistingHook('block-no-verify.js', claudeRaw);
}
if (hookEnabled('pre:bash:dev-server-block', ['standard', 'strict']) && process.platform !== 'win32') {
const segments = splitShellSegments(cmd);
const tmuxLauncher = /^\s*tmux\s+(new|new-session|new-window|split-window)\b/;
const devPattern = /\b(npm\s+run\s+dev|pnpm(?:\s+run)?\s+dev|yarn\s+dev|bun\s+run\s+dev)\b/;
const hasBlockedDev = segments.some(segment => devPattern.test(segment) && !tmuxLauncher.test(segment));
if (hasBlockedDev) {
console.error('[ECC] BLOCKED: Dev server must run in tmux for log access');
console.error('[ECC] Use: tmux new-session -d -s dev "npm run dev"');
process.exit(2);
}
runExistingHook('pre-bash-dev-server-block.js', claudeRaw);
}
if (
+8 -2
View File
@@ -1,6 +1,10 @@
#!/usr/bin/env node
const { readStdin } = require('./adapter');
const { readStdin, hookEnabled } = require('./adapter');
readStdin().then(raw => {
if (!hookEnabled('pre:prompt:secret-warning', ['minimal', 'standard', 'strict'])) {
process.stdout.write(raw);
return;
}
try {
const input = JSON.parse(raw);
const prompt = input.prompt || input.content || input.message || '';
@@ -18,6 +22,8 @@ readStdin().then(raw => {
break;
}
}
} catch {}
} catch {
// This warning hook remains advisory when Cursor sends malformed input.
}
process.stdout.write(raw);
}).catch(() => process.exit(0));
+17 -4
View File
@@ -1,13 +1,26 @@
#!/usr/bin/env node
const { readStdin } = require('./adapter');
readStdin().then(raw => {
const { readStdin, hookEnabled } = require('./adapter');
readStdin({ includeMetadata: true }).then(({ raw, truncated, readError }) => {
if (!hookEnabled('pre:tab-read:sensitive-file-block', ['minimal', 'standard', 'strict'])) {
process.stdout.write(raw);
return;
}
if (truncated || readError) {
console.error(truncated
? '[Cursor Hook] stdin exceeded the safety limit; blocking beforeTabFileRead'
: '[Cursor Hook] stdin read failed; blocking beforeTabFileRead');
process.exit(2);
}
try {
const input = JSON.parse(raw);
const filePath = input.path || input.file || '';
const filePath = [input.file_path, input.path, input.file]
.find(value => typeof value === 'string' && value.trim()) || '';
if (/\.(env|key|pem)$|\.env\.|credentials|secret/i.test(filePath)) {
console.error('[ECC] BLOCKED: Tab cannot read sensitive file: ' + filePath);
process.exit(2);
}
} catch {}
} catch {
// Preserve the existing fail-open behavior for malformed Cursor payloads.
}
process.stdout.write(raw);
}).catch(() => process.exit(0));
+9 -3
View File
@@ -1,7 +1,13 @@
#!/usr/bin/env node
const { readStdin, runExistingHook, transformToClaude } = require('./adapter');
const { readStdin, runExistingHook, transformToClaude, hookEnabled } = require('./adapter');
readStdin().then(raw => {
const claudeInput = JSON.parse(raw || '{}');
runExistingHook('pre-compact.js', transformToClaude(claudeInput));
try {
const claudeInput = JSON.parse(raw || '{}');
if (hookEnabled('pre:compact', ['standard', 'strict'])) {
runExistingHook('pre-compact.js', transformToClaude(claudeInput));
}
} catch {
// PreCompact is advisory; malformed payloads fail open.
}
process.stdout.write(raw);
}).catch(() => process.exit(0));
+60 -6
View File
@@ -1,10 +1,64 @@
#!/usr/bin/env node
const { readStdin, runExistingHook, transformToClaude, hookEnabled } = require('./adapter');
const {
hookEnabled,
mergeSessionStartOutputs,
normalizeSessionStartOutput,
readStdin,
runExistingHook,
transformToClaude,
} = require('./adapter');
readStdin().then(raw => {
const input = JSON.parse(raw || '{}');
const claudeInput = transformToClaude(input);
if (hookEnabled('session:start', ['minimal', 'standard', 'strict'])) {
runExistingHook('session-start.js', claudeInput);
let input = {};
try {
const parsed = JSON.parse(raw || '{}');
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
input = parsed;
}
} catch {
// The Cursor env helper handles malformed input and supplies a safe default.
}
process.stdout.write(raw);
const transformedInput = transformToClaude(input);
const claudeInput = {
...transformedInput,
hook_event_name: 'SessionStart',
source: 'startup',
};
let outputs = [];
if (!hookEnabled('session:start', ['minimal', 'standard', 'strict'])) {
process.stdout.write(JSON.stringify({}));
return;
}
const envResult = runExistingHook('cursor-session-env.js', raw, {
forwardStderr: true,
});
if (envResult && envResult.status === 0 && envResult.stdout) {
try {
outputs = [...outputs, normalizeSessionStartOutput(envResult.stdout)];
} catch {
console.error('[Cursor Hook] Ignoring invalid Cursor session environment output');
}
}
const sessionEnv = outputs[0]?.env || {};
const sessionResult = runExistingHook('session-start.js', claudeInput, {
env: {
...sessionEnv,
...(transformedInput.session_id
? { CLAUDE_SESSION_ID: transformedInput.session_id }
: {}),
},
forwardStderr: true,
});
if (sessionResult && sessionResult.status === 0 && sessionResult.stdout) {
try {
outputs = [
...outputs,
normalizeSessionStartOutput(sessionResult.stdout, { shared: true }),
];
} catch {
console.error('[Cursor Hook] Ignoring invalid shared SessionStart output');
}
}
process.stdout.write(JSON.stringify(mergeSessionStartOutputs(outputs)));
}).catch(() => process.exit(0));
+28 -7
View File
@@ -1,20 +1,41 @@
#!/usr/bin/env node
const { readStdin, runExistingHook, transformToClaude, hookEnabled } = require('./adapter');
const {
createStopFormatTypecheckOptions,
hookEnabled,
readStdin,
runExistingHook,
transformToClaude,
} = require('./adapter');
readStdin().then(raw => {
const input = JSON.parse(raw || '{}');
const claudeInput = transformToClaude(input);
const sharedOptions = {
env: claudeInput.session_id
? { CLAUDE_SESSION_ID: claudeInput.session_id }
: {},
};
if (hookEnabled('stop:check-console-log', ['standard', 'strict'])) {
runExistingHook('check-console-log.js', claudeInput);
}
if (hookEnabled('stop:session-end', ['minimal', 'standard', 'strict'])) {
runExistingHook('session-end.js', claudeInput);
runExistingHook('session-end.js', claudeInput, sharedOptions);
}
if (hookEnabled('stop:evaluate-session', ['minimal', 'standard', 'strict'])) {
runExistingHook('evaluate-session.js', claudeInput);
runExistingHook('evaluate-session.js', claudeInput, sharedOptions);
}
if (hookEnabled('stop:cost-tracker', ['minimal', 'standard', 'strict'])) {
runExistingHook('cost-tracker.js', claudeInput);
runExistingHook('cost-tracker.js', claudeInput, sharedOptions);
}
if (hookEnabled('stop:format-typecheck', ['standard', 'strict'])) {
runExistingHook(
'stop-format-typecheck.js',
claudeInput,
createStopFormatTypecheckOptions(sharedOptions.env)
);
}
if (hookEnabled('stop:check-console-log', ['standard', 'strict'])) {
runExistingHook('check-console-log.js', claudeInput, {
...sharedOptions,
forwardStderr: true,
});
}
process.stdout.write(raw);
+8 -2
View File
@@ -1,10 +1,16 @@
#!/usr/bin/env node
const { readStdin } = require('./adapter');
const { readStdin, hookEnabled } = require('./adapter');
readStdin().then(raw => {
if (!hookEnabled('subagent:start:audit', ['standard', 'strict'])) {
process.stdout.write(raw);
return;
}
try {
const input = JSON.parse(raw);
const agent = input.agent_name || input.agent || 'unknown';
console.error(`[ECC] Agent spawned: ${agent}`);
} catch {}
} catch {
// Audit-only hook: malformed payloads must not interrupt the agent loop.
}
process.stdout.write(raw);
}).catch(() => process.exit(0));
+8 -2
View File
@@ -1,10 +1,16 @@
#!/usr/bin/env node
const { readStdin } = require('./adapter');
const { readStdin, hookEnabled } = require('./adapter');
readStdin().then(raw => {
if (!hookEnabled('subagent:stop:audit', ['standard', 'strict'])) {
process.stdout.write(raw);
return;
}
try {
const input = JSON.parse(raw);
const agent = input.agent_name || input.agent || 'unknown';
console.error(`[ECC] Agent completed: ${agent}`);
} catch {}
} catch {
// Audit-only hook: malformed payloads must not interrupt the agent loop.
}
process.stdout.write(raw);
}).catch(() => process.exit(0));
+5 -5
View File
@@ -1290,7 +1290,7 @@ Treat `stable`, `beta`, `experimental`, and `instruction-only` below as capabili
|---|---|---|---|
| Claude Code | Stable primary | Plugin or selective installer | The plugin advertises the installed catalog to the model; use a selective/manual profile when context footprint matters. Optional shell-backed skills are not portable to every OS. |
| Codex | Supported native plugin | Codex marketplace plugin or repo config | Native hooks require an explicit trust decision and do not use Claude's hook profiles. The legacy sync is compatibility-only. |
| Cursor | Beta project adapter | Selective installer into `.cursor/` | Agent discovery varies by Cursor build, and ECC's installer paths do not yet expose identical hook sets ([#2419](https://github.com/affaan-m/ECC/issues/2419)). |
| Cursor | Beta project adapter | Selective installer into `.cursor/` | Agent discovery varies by Cursor build; hook events use profile-gated dispatchers, but full Claude feature parity is not claimed. |
| OpenCode | Beta built plugin | Build plugin, then selective installer | ECC ships a subset of the catalog; connect a provider and select a model in OpenCode ([#2617](https://github.com/affaan-m/ECC/issues/2617)). |
| GitHub Copilot Chat | Instruction-only | Checked-in instructions and prompt files | ECC hooks are not adapted. |
| GitHub Copilot CLI | Beta project adapter | `--target copilot` places skills and adapted agents in `.github/` | ECC hooks, rules, and command shims are not installed by this target. |
@@ -1436,7 +1436,7 @@ ECC provides Cursor IDE support with hooks, rules, agents, skills, commands, and
| Component | Count | Details |
|-----------|-------|---------|
| Hook Events | 15 | sessionStart, beforeShellExecution, afterFileEdit, beforeMCPExecution, beforeSubmitPrompt, and 10 more |
| Hook Scripts | 16 | Thin Node.js scripts delegating to `scripts/hooks/` via shared adapter |
| Hook Entrypoints | 15 | Thin Node.js event dispatchers delegating to `scripts/hooks/` via shared adapter |
| Rules | 34 | 9 common (alwaysApply) + 25 language-specific (TypeScript, Python, Go, Swift, PHP) |
| Agents | 48 | `.cursor/agents/ecc-*.md` when installed; prefixed to avoid collisions with user or marketplace agents |
| Skills | Shared + Bundled | `.cursor/skills/` for translated additions |
@@ -1470,7 +1470,7 @@ Continuous learning v2 instincts remain separate under `CLV2_HOMUNCULUS_DIR` (de
#### Hook architecture (DRY adapter pattern)
Cursor has **more hook events than Claude Code** (20 vs 8). The `.cursor/hooks/adapter.js` module transforms Cursor's stdin JSON to Claude Code's format, allowing existing `scripts/hooks/*.js` to be reused without duplication.
Cursor has **more hook events than Claude Code** (20 vs 8). The `.cursor/hooks/adapter.js` module transforms Cursor's stdin JSON to Claude Code's format, allowing existing `scripts/hooks/*.js` to be reused without duplication. Each Cursor event is registered with one event-level dispatcher so enabled checks run in a deterministic order and remain compatible with Cursor versions that execute only the first matching entry. The adapter resolves the shared runtime from both the ECC checkout and an installed project's `.cursor/scripts/` tree.
```
Cursor stdin JSON -> adapter.js -> transforms -> scripts/hooks/*.js
@@ -1478,8 +1478,8 @@ Cursor stdin JSON -> adapter.js -> transforms -> scripts/hooks/*.js
```
Key hooks:
- **beforeShellExecution**: Blocks dev servers outside tmux (exit 2), git push review
- **afterFileEdit**: Auto-format + TypeScript check + console.log warning
- **beforeShellExecution**: Blocks git hook bypasses and dev servers outside tmux (exit 2), then adds profile-gated shell reminders
- **afterFileEdit / stop**: Accumulates edited JS/TS files and runs one profile-gated format/typecheck batch at Stop, alongside the console.log audit
- **beforeSubmitPrompt**: Detects secrets (sk-, ghp_, AKIA patterns) in prompts
- **beforeTabFileRead**: Blocks Tab from reading .env, .key, .pem files (exit 2)
- **beforeMCPExecution / afterMCPExecution**: MCP audit logging
+1 -1
View File
@@ -3,7 +3,7 @@
"hooks": {
"sessionStart": [
{
"command": "node .cursor/scripts/hooks/cursor-session-env.js"
"command": "node .cursor/hooks/session-start.js"
}
]
}
+26 -3
View File
@@ -26,7 +26,23 @@ const { findProjectRoot, detectFormatter, resolveFormatterBin } = require('../li
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;
const DEFAULT_TOTAL_BUDGET_MS = 270_000;
const MAX_TOTAL_BUDGET_MS = 270_000;
function getTotalBudgetMs(env = process.env) {
const raw = String(env.ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS || '').trim();
if (!/^\d+$/.test(raw)) return DEFAULT_TOTAL_BUDGET_MS;
const parsed = Number(raw);
return Number.isSafeInteger(parsed) && parsed > 0
? Math.min(parsed, MAX_TOTAL_BUDGET_MS)
: DEFAULT_TOTAL_BUDGET_MS;
}
function getPerBatchBudgetMs(totalBudgetMs, totalBatches) {
return totalBatches > 0
? Math.max(1, Math.floor(totalBudgetMs / totalBatches))
: 60_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\...".
@@ -197,7 +213,8 @@ function main() {
// 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;
const totalBudgetMs = getTotalBudgetMs();
const perBatchMs = getPerBatchBudgetMs(totalBudgetMs, totalBatches);
for (const [root, batch] of byProjectRoot) formatBatch(root, batch, perBatchMs);
for (const [tsDir, batch] of byTsConfigDir) typecheckBatch(tsDir, batch, perBatchMs);
@@ -248,4 +265,10 @@ if (require.main === module) {
});
}
module.exports = { run, parseAccumulator, isPluginClonePath };
module.exports = {
run,
parseAccumulator,
isPluginClonePath,
getTotalBudgetMs,
getPerBatchBudgetMs,
};
+25 -4
View File
@@ -88,15 +88,19 @@ module.exports = createInstallTargetAdapter({
}));
}).sort((left, right) => {
const getPriority = value => {
if (value === '.cursor') {
if (value === 'hooks') {
return 0;
}
if (value === 'rules') {
if (value === '.cursor') {
return 1;
}
return 2;
if (value === 'rules') {
return 2;
}
return 3;
};
const leftPriority = getPriority(left.sourceRelativePath);
@@ -161,6 +165,23 @@ module.exports = createInstallTargetAdapter({
}));
}
if (sourceRelativePath === 'hooks') {
return takeUniqueOperations([
createManagedOperation({
moduleId: module.id,
sourceRelativePath: path.join('.cursor', 'hooks'),
destinationPath: path.join(targetRoot, 'hooks'),
strategy: 'preserve-relative-path',
}),
createManagedOperation({
moduleId: module.id,
sourceRelativePath: path.join('.cursor', 'hooks.json'),
destinationPath: path.join(targetRoot, 'hooks.json'),
strategy: 'preserve-relative-path',
}),
]);
}
if (sourceRelativePath === '.cursor') {
const cursorRoot = path.join(repoRoot, '.cursor');
if (!fs.existsSync(cursorRoot) || !fs.statSync(cursorRoot).isDirectory()) {
@@ -169,7 +190,7 @@ module.exports = createInstallTargetAdapter({
const childOperations = fs.readdirSync(cursorRoot, { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name))
.filter(entry => entry.name !== 'rules')
.filter(entry => !['rules', 'hooks', 'hooks.json'].includes(entry.name))
.map(entry => createManagedOperation({
moduleId: module.id,
sourceRelativePath: path.join('.cursor', entry.name),
+20 -8
View File
@@ -1,9 +1,9 @@
/**
* Tests for .cursor/hooks/before-shell-execution-block-no-verify.js
* Tests for block-no-verify behavior in the Cursor beforeShell dispatcher.
*
* Issue #2107: previously .cursor/hooks.json wired `npx block-no-verify@1.1.2`,
* which over-matches and blocks legitimate commits whose message body
* mentions `--no-verify` or `-n`. The wrapper added in this PR delegates
* mentions `--no-verify` or `-n`. The event-level dispatcher delegates
* to the local scripts/hooks/block-no-verify.js so Cursor users get the
* same flag-position-aware matcher Claude Code already uses.
*/
@@ -14,17 +14,23 @@ const assert = require('assert');
const path = require('path');
const { spawnSync } = require('child_process');
const wrapper = path.join(
const dispatcher = path.join(
__dirname, '..', '..',
'.cursor', 'hooks', 'before-shell-execution-block-no-verify.js'
'.cursor', 'hooks', 'before-shell-execution.js'
);
function runWrapper(input, env = {}) {
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
const result = spawnSync('node', [wrapper], {
const result = spawnSync('node', [dispatcher], {
input: rawInput,
encoding: 'utf8',
env: { ...process.env, ECC_HOOK_PROFILE: 'standard', ...env },
env: {
...process.env,
ECC_HOOKS_ENABLED: 'true',
ECC_HOOK_PROFILE: 'standard',
ECC_DISABLED_HOOKS: 'pre:bash:dev-server-block',
...env,
},
timeout: 15000,
stdio: ['pipe', 'pipe', 'pipe'],
});
@@ -51,7 +57,7 @@ function test(name, fn) {
let passed = 0;
let failed = 0;
console.log('\ncursor block-no-verify wrapper tests');
console.log('\ncursor block-no-verify dispatcher tests');
console.log('─'.repeat(50));
// --- Cursor input shapes ---
@@ -129,6 +135,12 @@ if (test('handles malformed JSON gracefully (treats raw as command string)', ()
assert.strictEqual(r.code, 0, `expected 0, got ${r.code}: ${r.stderr}`);
})) passed++; else failed++;
if (test('blocks a bypass attempt supplied as a raw command string', () => {
const r = runWrapper('git commit --no-verify -m "msg"');
assert.strictEqual(r.code, 2, `expected 2, got ${r.code}: ${r.stderr}`);
assert.ok(r.stderr.includes('BLOCKED'), `stderr should contain BLOCKED: ${r.stderr}`);
})) passed++; else failed++;
// --- Disable via ECC_DISABLED_HOOKS ---
if (test('respects ECC_DISABLED_HOOKS=pre:bash:block-no-verify', () => {
@@ -136,7 +148,7 @@ if (test('respects ECC_DISABLED_HOOKS=pre:bash:block-no-verify', () => {
{ command: 'git commit --no-verify -m "msg"' },
{ ECC_DISABLED_HOOKS: 'pre:bash:block-no-verify' }
);
// When the hook is disabled, the wrapper should pass through (exit 0)
// When the hook is disabled, the dispatcher should pass through (exit 0)
// even on a real bypass attempt.
assert.strictEqual(r.code, 0, `expected 0 when disabled, got ${r.code}: ${r.stderr}`);
})) passed++; else failed++;
+699
View File
@@ -0,0 +1,699 @@
/**
* Contract tests for Cursor's event-level hook dispatchers.
*
* Issue #2419: Cursor installs must expose one command per event, preserve
* blocking exit codes while dispatching multiple checks, and resolve shared
* runtime modules from the installed .cursor/ tree.
*/
'use strict';
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const REPO_ROOT = path.join(__dirname, '..', '..');
const HOOKS_CONFIG = path.join(REPO_ROOT, '.cursor', 'hooks.json');
const BEFORE_SHELL = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-shell-execution.js');
const STOP = path.join(REPO_ROOT, '.cursor', 'hooks', 'stop.js');
const BEFORE_MCP = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-mcp-execution.js');
const AFTER_MCP = path.join(REPO_ROOT, '.cursor', 'hooks', 'after-mcp-execution.js');
const AFTER_SHELL = path.join(REPO_ROOT, '.cursor', 'hooks', 'after-shell-execution.js');
const AFTER_TAB_EDIT = path.join(REPO_ROOT, '.cursor', 'hooks', 'after-tab-file-edit.js');
const BEFORE_READ = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-read-file.js');
const BEFORE_PROMPT = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-submit-prompt.js');
const BEFORE_TAB_READ = path.join(REPO_ROOT, '.cursor', 'hooks', 'before-tab-file-read.js');
const PRE_COMPACT = path.join(REPO_ROOT, '.cursor', 'hooks', 'pre-compact.js');
const SUBAGENT_START = path.join(REPO_ROOT, '.cursor', 'hooks', 'subagent-start.js');
const SUBAGENT_STOP = path.join(REPO_ROOT, '.cursor', 'hooks', 'subagent-stop.js');
const AFTER_FILE_EDIT = path.join(REPO_ROOT, '.cursor', 'hooks', 'after-file-edit.js');
const { createStopFormatTypecheckOptions } = require('../../.cursor/hooks/adapter');
function test(name, fn) {
try {
fn();
console.log(` ✓ ${name}`);
return true;
} catch (error) {
console.log(` ✗ ${name}`);
console.log(` ${error.message}`);
return false;
}
}
function runHook(scriptPath, input, env = {}, cwd = REPO_ROOT) {
const raw = typeof input === 'string' ? input : JSON.stringify(input);
const childEnv = {
...process.env,
ECC_HOOK_PROFILE: 'standard',
...env,
};
if (!Object.hasOwn(env, 'ECC_HOOKS_ENABLED')) {
delete childEnv.ECC_HOOKS_ENABLED;
}
if (!Object.hasOwn(env, 'ECC_DISABLED_HOOKS')) {
delete childEnv.ECC_DISABLED_HOOKS;
}
if (!Object.hasOwn(env, 'CLAUDE_SESSION_ID')) {
delete childEnv.CLAUDE_SESSION_ID;
}
if (!Object.hasOwn(env, 'ECC_SESSION_ID')) {
delete childEnv.ECC_SESSION_ID;
}
for (const key of [
'CLAUDE_PLUGIN_OPTION_HOOKS_ENABLED',
'CLAUDE_PLUGIN_OPTION_HOOK_PROFILE',
'ECC_HOOK_CONFIG',
'CLAUDE_PLUGIN_ROOT',
'ECC_PLUGIN_ROOT',
]) {
if (!Object.hasOwn(env, key)) {
delete childEnv[key];
}
}
return spawnSync(process.execPath, [scriptPath], {
cwd,
env: childEnv,
input: raw,
encoding: 'utf8',
timeout: 30000,
});
}
function accumulatorPath(sessionId, cwd) {
const raw = sessionId
|| crypto.createHash('sha1').update(cwd).digest('hex').slice(0, 12);
const safeId = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
return path.join(os.tmpdir(), `ecc-edited-${safeId}.txt`);
}
function writeFile(filePath, content) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf8');
}
function createInstalledCursorFixture(options = {}) {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cursor-dispatcher-'));
const cursorRoot = path.join(projectRoot, '.cursor');
const hooksRoot = path.join(cursorRoot, 'hooks');
const scriptsRoot = path.join(cursorRoot, 'scripts');
fs.mkdirSync(hooksRoot, { recursive: true });
for (const fileName of [
'adapter.js',
'after-file-edit.js',
'before-shell-execution.js',
'session-start.js',
'stop.js',
]) {
fs.copyFileSync(
path.join(REPO_ROOT, '.cursor', 'hooks', fileName),
path.join(hooksRoot, fileName)
);
}
fs.mkdirSync(path.join(scriptsRoot, 'lib'), { recursive: true });
if (options.includeHookFlags !== false) {
fs.copyFileSync(
path.join(REPO_ROOT, 'scripts', 'lib', 'hook-flags.js'),
path.join(scriptsRoot, 'lib', 'hook-flags.js')
);
}
writeFile(
path.join(scriptsRoot, 'lib', 'shell-split.js'),
"module.exports = { splitShellSegments(value) { return [value]; } };\n"
);
writeFile(
path.join(scriptsRoot, 'hooks', 'block-no-verify.js'),
[
"'use strict';",
"const fs = require('fs');",
"const command = JSON.parse(fs.readFileSync(0, 'utf8')).tool_input.command;",
"if (command.includes('--no-verify')) {",
" process.stderr.write('BLOCKED: installed runtime\\n');",
' process.exit(2);',
'}',
'',
].join('\n')
);
writeFile(
path.join(scriptsRoot, 'hooks', 'pre-bash-dev-server-block.js'),
"process.stdout.write(require('fs').readFileSync(0, 'utf8'));\n"
);
writeFile(
path.join(scriptsRoot, 'hooks', 'stop-format-typecheck.js'),
[
"'use strict';",
"const fs = require('fs');",
"fs.appendFileSync(process.env.CURSOR_HOOK_LOG, `stop-format-typecheck:${process.env.ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS}\\n`);",
"process.stdout.write(require('fs').readFileSync(0, 'utf8'));",
'',
].join('\n')
);
writeFile(
path.join(scriptsRoot, 'hooks', 'session-end.js'),
[
"'use strict';",
"const fs = require('fs');",
"fs.appendFileSync(process.env.CURSOR_HOOK_LOG, 'session-end\\n');",
"process.stdout.write(require('fs').readFileSync(0, 'utf8'));",
'',
].join('\n')
);
writeFile(
path.join(scriptsRoot, 'hooks', 'post-edit-accumulator.js'),
[
"'use strict';",
"const fs = require('fs');",
"fs.appendFileSync(process.env.CURSOR_HOOK_LOG, 'post-edit-accumulator\\n');",
"process.stdout.write(require('fs').readFileSync(0, 'utf8'));",
'',
].join('\n')
);
writeFile(
path.join(scriptsRoot, 'hooks', 'post-edit-console-warn.js'),
"process.stdout.write(require('fs').readFileSync(0, 'utf8'));\n"
);
writeFile(
path.join(scriptsRoot, 'hooks', 'design-quality-check.js'),
"process.stdout.write(require('fs').readFileSync(0, 'utf8'));\n"
);
writeFile(
path.join(scriptsRoot, 'hooks', 'cursor-session-env.js'),
"process.stdout.write(JSON.stringify({ env: { ECC_AGENT_DATA_HOME: '/tmp/cursor-ecc' }, additional_context: 'cursor env' }));\n"
);
writeFile(
path.join(scriptsRoot, 'hooks', 'session-start.js'),
[
"const fs = require('fs');",
"const input = JSON.parse(fs.readFileSync(0, 'utf8'));",
"if (input.hook_event_name !== 'SessionStart' || input.source !== 'startup') process.exit(3);",
"if (process.env.ECC_AGENT_DATA_HOME !== '/tmp/cursor-ecc') process.exit(4);",
"process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'shared context' } }));",
'',
].join('\n')
);
return {
projectRoot,
beforeShell: path.join(hooksRoot, 'before-shell-execution.js'),
afterFileEdit: path.join(hooksRoot, 'after-file-edit.js'),
sessionStart: path.join(hooksRoot, 'session-start.js'),
stop: path.join(hooksRoot, 'stop.js'),
logPath: path.join(projectRoot, 'hook.log'),
};
}
let passed = 0;
let failed = 0;
console.log('\nCursor hook dispatcher contract');
console.log('-'.repeat(55));
if (test('registers at most one command for every Cursor event', () => {
const config = JSON.parse(fs.readFileSync(HOOKS_CONFIG, 'utf8'));
for (const [eventName, entries] of Object.entries(config.hooks)) {
assert.strictEqual(
entries.length,
1,
`${eventName} must use one event-level dispatcher`
);
}
assert.strictEqual(
config.hooks.beforeShellExecution[0].command,
'node .cursor/hooks/before-shell-execution.js'
);
})) passed++; else failed++;
if (test('Cursor sessionStart scaffold points at the event dispatcher', () => {
const scaffold = JSON.parse(fs.readFileSync(
path.join(REPO_ROOT, 'scaffolds', 'cursor', 'hooks.json'),
'utf8'
));
assert.deepStrictEqual(scaffold.hooks.sessionStart, [{
command: 'node .cursor/hooks/session-start.js',
}]);
})) passed++; else failed++;
if (test('beforeShellExecution dispatcher blocks git hook bypass attempts', () => {
const result = runHook(BEFORE_SHELL, { command: 'git commit --no-verify -m test' });
assert.strictEqual(result.status, 2, result.stderr);
assert.match(result.stderr, /BLOCKED/);
})) passed++; else failed++;
if (test('beforeShellExecution dispatcher honors the master hook switch', () => {
const result = runHook(
BEFORE_SHELL,
{ command: 'git commit --no-verify -m test' },
{ ECC_HOOKS_ENABLED: 'false' }
);
assert.strictEqual(result.status, 0, result.stderr);
})) passed++; else failed++;
if (test('beforeShellExecution dispatcher preserves the dev-server guard', () => {
if (process.platform === 'win32') {
console.log(' (tmux guard is not supported on Windows; skipping)');
return;
}
const result = runHook(BEFORE_SHELL, { command: 'npm run dev' });
assert.strictEqual(result.status, 2, result.stderr);
assert.match(result.stderr, /Dev server must run in tmux/);
})) passed++; else failed++;
if (test('Cursor-native security hooks honor the master hook switch', () => {
const disabledEnv = { ECC_HOOKS_ENABLED: 'false' };
const cases = [
[BEFORE_READ, { path: '.env' }],
[BEFORE_PROMPT, { prompt: 'token sk-123456789012345678901234' }],
[BEFORE_TAB_READ, { path: 'credentials.pem' }],
];
for (const [scriptPath, input] of cases) {
const result = runHook(scriptPath, input, disabledEnv);
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(result.stderr, '', `${path.basename(scriptPath)} should be disabled`);
}
})) passed++; else failed++;
if (test('Cursor-sensitive file hooks recognize the official file_path field', () => {
const warning = runHook(BEFORE_READ, { file_path: '.env' });
assert.strictEqual(warning.status, 0, warning.stderr);
assert.match(warning.stderr, /Reading sensitive file/);
const blocked = runHook(BEFORE_TAB_READ, { file_path: 'credentials.pem' });
assert.strictEqual(blocked.status, 2, blocked.stderr);
assert.match(blocked.stderr, /BLOCKED/);
})) passed++; else failed++;
if (test('invalid path field types cannot shadow a valid string fallback', () => {
const input = { file_path: {}, path: '.env' };
const warning = runHook(BEFORE_READ, input);
assert.strictEqual(warning.status, 0, warning.stderr);
assert.match(warning.stderr, /Reading sensitive file/);
const blocked = runHook(BEFORE_TAB_READ, input);
assert.strictEqual(blocked.status, 2, blocked.stderr);
assert.match(blocked.stderr, /BLOCKED/);
})) passed++; else failed++;
if (test('Cursor-native audit hooks stay disabled in the minimal profile', () => {
const cases = [
[BEFORE_MCP, { server: 'example', tool: 'read' }],
[AFTER_MCP, { server: 'example', tool: 'read' }],
[AFTER_SHELL, { command: 'gh pr create', output: 'https://github.com/a/b/pull/1' }],
[AFTER_TAB_EDIT, { path: 'src/app.js' }],
[PRE_COMPACT, {}],
[SUBAGENT_START, { agent_name: 'explore' }],
[SUBAGENT_STOP, { agent_name: 'explore' }],
];
for (const [scriptPath, input] of cases) {
const result = runHook(scriptPath, input, { ECC_HOOK_PROFILE: 'minimal' });
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(result.stderr, '', `${path.basename(scriptPath)} should be disabled`);
}
})) passed++; else failed++;
if (test('disabled preCompact preserves malformed input unchanged', () => {
const raw = '{not-json';
const result = runHook(PRE_COMPACT, raw, { ECC_HOOK_PROFILE: 'minimal' });
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(result.stdout, raw);
})) passed++; else failed++;
if (test('Stop dispatcher runs batch format/typecheck in standard profile', () => {
const sessionId = `cursor-stop-standard-${process.pid}`;
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cursor-stop-'));
const editedFile = path.join(tempRoot, 'edited.js');
const accumFile = accumulatorPath(sessionId, tempRoot);
writeFile(editedFile, 'const answer = 42;\n');
writeFile(accumFile, `${editedFile}\n`);
try {
const result = runHook(STOP, { conversation_id: sessionId }, {
CLAUDE_SESSION_ID: sessionId,
ECC_DISABLED_HOOKS: [
'stop:check-console-log',
'stop:session-end',
'stop:evaluate-session',
'stop:cost-tracker',
].join(','),
}, tempRoot);
assert.strictEqual(result.status, 0, result.stderr);
assert.ok(!fs.existsSync(accumFile), 'Stop should consume the edit accumulator');
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
fs.rmSync(accumFile, { force: true });
}
})) passed++; else failed++;
if (test('afterFileEdit maps Cursor file_path and conversation_id', () => {
const conversationId = `cursor-edit-${process.pid}`;
const accumFile = accumulatorPath(conversationId, REPO_ROOT);
fs.rmSync(accumFile, { force: true });
try {
const editedFile = path.join(REPO_ROOT, 'src', 'cursor-edit.js');
const result = runHook(AFTER_FILE_EDIT, {
conversation_id: conversationId,
file_path: editedFile,
}, {
ECC_DISABLED_HOOKS: [
'post:edit:console-warn',
'post:edit:design-quality-check',
].join(','),
});
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(fs.readFileSync(accumFile, 'utf8'), `${editedFile}\n`);
} finally {
fs.rmSync(accumFile, { force: true });
}
})) passed++; else failed++;
if (test('parallel Cursor conversations keep separate edit accumulators', () => {
const firstId = `cursor-first-${process.pid}`;
const secondId = `cursor-second-${process.pid}`;
const firstAccum = accumulatorPath(firstId, REPO_ROOT);
const secondAccum = accumulatorPath(secondId, REPO_ROOT);
fs.rmSync(firstAccum, { force: true });
fs.rmSync(secondAccum, { force: true });
try {
const env = {
ECC_DISABLED_HOOKS: [
'post:edit:console-warn',
'post:edit:design-quality-check',
].join(','),
};
assert.strictEqual(runHook(AFTER_FILE_EDIT, {
conversation_id: firstId,
file_path: path.join(REPO_ROOT, 'src', 'first.js'),
}, env).status, 0);
assert.strictEqual(runHook(AFTER_FILE_EDIT, {
conversation_id: secondId,
file_path: path.join(REPO_ROOT, 'src', 'second.js'),
}, env).status, 0);
assert.ok(fs.existsSync(firstAccum), 'First conversation should own an accumulator');
assert.ok(fs.existsSync(secondAccum), 'Second conversation should own an accumulator');
const stopped = runHook(STOP, { conversation_id: firstId }, {
ECC_DISABLED_HOOKS: [
'stop:check-console-log',
'stop:session-end',
'stop:evaluate-session',
'stop:cost-tracker',
].join(','),
});
assert.strictEqual(stopped.status, 0, stopped.stderr);
assert.ok(!fs.existsSync(firstAccum), 'First Stop should consume only its accumulator');
assert.ok(fs.existsSync(secondAccum), 'Second conversation accumulator must remain');
} finally {
fs.rmSync(firstAccum, { force: true });
fs.rmSync(secondAccum, { force: true });
}
})) passed++; else failed++;
if (test('afterFileEdit forwards user-visible console warnings', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cursor-warning-'));
const editedFile = path.join(tempRoot, 'warning.js');
writeFile(editedFile, 'console.log("debug");\n');
try {
const result = runHook(AFTER_FILE_EDIT, { file_path: editedFile }, {
ECC_DISABLED_HOOKS: [
'post:edit:accumulator',
'post:edit:design-quality-check',
].join(','),
}, tempRoot);
assert.strictEqual(result.status, 0, result.stderr);
assert.match(result.stderr, /console\.log found/);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('Stop reserves time for lifecycle hooks before the format batch', () => {
const fixture = createInstalledCursorFixture();
try {
const result = runHook(fixture.stop, { conversation_id: 'ordered-stop' }, {
CURSOR_HOOK_LOG: fixture.logPath,
ECC_DISABLED_HOOKS: [
'stop:check-console-log',
'stop:evaluate-session',
'stop:cost-tracker',
].join(','),
}, fixture.projectRoot);
assert.strictEqual(result.status, 0, result.stderr);
assert.deepStrictEqual(
fs.readFileSync(fixture.logPath, 'utf8').trim().split('\n'),
['session-end', 'stop-format-typecheck:210000']
);
assert.deepStrictEqual(createStopFormatTypecheckOptions({ CLAUDE_SESSION_ID: 'abc' }), {
timeout: 225000,
forwardStderr: true,
env: {
CLAUDE_SESSION_ID: 'abc',
ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS: '210000',
},
});
} finally {
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('Stop dispatcher leaves batch format/typecheck disabled in minimal profile', () => {
const sessionId = `cursor-stop-minimal-${process.pid}`;
const accumFile = accumulatorPath(sessionId, REPO_ROOT);
writeFile(accumFile, `${path.join(REPO_ROOT, 'README.md')}\n`);
try {
const result = runHook(STOP, { conversation_id: sessionId }, {
CLAUDE_SESSION_ID: sessionId,
ECC_HOOK_PROFILE: 'minimal',
ECC_DISABLED_HOOKS: [
'stop:session-end',
'stop:evaluate-session',
'stop:cost-tracker',
].join(','),
});
assert.strictEqual(result.status, 0, result.stderr);
assert.ok(fs.existsSync(accumFile), 'Minimal profile must not run format/typecheck');
} finally {
fs.rmSync(accumFile, { force: true });
}
})) passed++; else failed++;
if (test('afterFileEdit does not accumulate files in the minimal profile', () => {
const sessionId = `cursor-edit-minimal-${process.pid}`;
const accumFile = accumulatorPath(sessionId, REPO_ROOT);
fs.rmSync(accumFile, { force: true });
try {
const result = runHook(
AFTER_FILE_EDIT,
{ path: path.join(REPO_ROOT, 'example.js') },
{ CLAUDE_SESSION_ID: sessionId, ECC_HOOK_PROFILE: 'minimal' }
);
assert.strictEqual(result.status, 0, result.stderr);
assert.ok(!fs.existsSync(accumFile), 'Minimal profile must not accumulate edit paths');
} finally {
fs.rmSync(accumFile, { force: true });
}
})) passed++; else failed++;
if (test('installed Cursor dispatchers resolve shared hooks inside .cursor/scripts', () => {
const fixture = createInstalledCursorFixture();
try {
const blocked = runHook(
fixture.beforeShell,
{ command: 'git commit --no-verify -m test' },
{},
fixture.projectRoot
);
assert.strictEqual(blocked.status, 2, blocked.stderr);
assert.match(blocked.stderr, /installed runtime/);
const edited = runHook(
fixture.afterFileEdit,
{ path: path.join(fixture.projectRoot, 'src', 'app.js') },
{ CURSOR_HOOK_LOG: fixture.logPath },
fixture.projectRoot
);
assert.strictEqual(edited.status, 0, edited.stderr);
assert.strictEqual(fs.readFileSync(fixture.logPath, 'utf8'), 'post-edit-accumulator\n');
} finally {
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('installed Cursor hooks keep profile controls without the shared helper', () => {
const fixture = createInstalledCursorFixture({ includeHookFlags: false });
try {
const disabled = runHook(
fixture.beforeShell,
{ command: 'git commit --no-verify -m test' },
{ ECC_HOOKS_ENABLED: 'false' },
fixture.projectRoot
);
assert.strictEqual(disabled.status, 0, disabled.stderr);
const enabled = runHook(
fixture.beforeShell,
{ command: 'git commit --no-verify -m test' },
{ ECC_HOOKS_ENABLED: 'true' },
fixture.projectRoot
);
assert.strictEqual(enabled.status, 2, enabled.stderr);
} finally {
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('installed Stop dispatcher resolves hooks inside .cursor/scripts', () => {
const fixture = createInstalledCursorFixture();
try {
const result = runHook(
fixture.stop,
{ conversation_id: 'installed-stop' },
{
CURSOR_HOOK_LOG: fixture.logPath,
ECC_DISABLED_HOOKS: [
'stop:check-console-log',
'stop:session-end',
'stop:evaluate-session',
'stop:cost-tracker',
].join(','),
},
fixture.projectRoot
);
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(
fs.readFileSync(fixture.logPath, 'utf8'),
'stop-format-typecheck:210000\n'
);
} finally {
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('installed sessionStart dispatcher combines env and shared context', () => {
const fixture = createInstalledCursorFixture();
try {
const result = runHook(
fixture.sessionStart,
{ conversation_id: 'installed-session' },
{},
fixture.projectRoot
);
assert.strictEqual(result.status, 0, result.stderr);
assert.deepStrictEqual(JSON.parse(result.stdout), {
env: { ECC_AGENT_DATA_HOME: '/tmp/cursor-ecc' },
additional_context: 'cursor env\n\nshared context',
});
} finally {
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('installed sessionStart keeps environment fallback for malformed input', () => {
const fixture = createInstalledCursorFixture();
try {
for (const raw of ['{not-json', 'null']) {
const result = runHook(fixture.sessionStart, raw, {}, fixture.projectRoot);
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(JSON.parse(result.stdout).env.ECC_AGENT_DATA_HOME, '/tmp/cursor-ecc');
}
} finally {
fs.rmSync(fixture.projectRoot, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('sessionStart dispatcher honors the master hook switch', () => {
const result = runHook(
path.join(REPO_ROOT, '.cursor', 'hooks', 'session-start.js'),
{ conversation_id: 'disabled-session' },
{ ECC_HOOKS_ENABLED: 'false' }
);
assert.strictEqual(result.status, 0, result.stderr);
assert.deepStrictEqual(JSON.parse(result.stdout), {});
})) passed++; else failed++;
if (test('oversized beforeShellExecution input fails closed', () => {
const oversizedPayloads = [
JSON.stringify({
command: 'git commit --no-verify',
padding: 'x'.repeat(1024 * 1024),
}),
JSON.stringify({
command: 'git commit --no-verify',
padding: '界'.repeat(400000),
}),
];
for (const oversized of oversizedPayloads) {
assert.ok(Buffer.byteLength(oversized, 'utf8') > 1024 * 1024);
const result = runHook(BEFORE_SHELL, oversized);
assert.strictEqual(result.status, 2, result.stderr);
assert.strictEqual(result.stdout, '');
assert.match(result.stderr, /stdin exceeded.*blocking/i);
}
})) passed++; else failed++;
if (test('oversized beforeShellExecution input honors explicit guard disablement', () => {
const oversized = JSON.stringify({
command: 'git commit --no-verify',
padding: 'x'.repeat(1024 * 1024),
});
for (const env of [
{ ECC_HOOKS_ENABLED: 'false' },
{
ECC_DISABLED_HOOKS: [
'pre:bash:block-no-verify',
'pre:bash:dev-server-block',
].join(','),
},
]) {
const result = runHook(BEFORE_SHELL, oversized, env);
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(result.stdout, '');
assert.match(result.stderr, /stdin exceeded.*suppressing/i);
}
})) passed++; else failed++;
if (test('oversized beforeTabFileRead input fails closed', () => {
const oversized = JSON.stringify({
file_path: '.env',
padding: '界'.repeat(400000),
});
const result = runHook(BEFORE_TAB_READ, oversized);
assert.strictEqual(result.status, 2, result.stderr);
assert.strictEqual(result.stdout, '');
assert.match(result.stderr, /stdin exceeded.*blocking/i);
})) passed++; else failed++;
if (test('readStdin exposes stream failures to blocking dispatchers', () => {
const probe = [
"const { EventEmitter } = require('events');",
`const { readStdin } = require(${JSON.stringify(path.join(REPO_ROOT, '.cursor', 'hooks', 'adapter.js'))});`,
"const stream = new EventEmitter();",
"stream.setEncoding = () => {};",
"readStdin({ includeMetadata: true, stream }).then(result => process.stdout.write(JSON.stringify(result)));",
"setImmediate(() => stream.emit('error', new Error('synthetic read failure')));",
].join('\n');
const result = spawnSync(process.execPath, ['-e', probe], {
encoding: 'utf8',
timeout: 5000,
});
assert.strictEqual(result.status, 0, result.stderr);
assert.deepStrictEqual(JSON.parse(result.stdout), {
raw: '',
truncated: false,
readError: true,
});
})) passed++; else failed++;
console.log('-'.repeat(55));
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
+27 -1
View File
@@ -13,7 +13,12 @@ const os = require('os');
const path = require('path');
const accumulator = require('../../scripts/hooks/post-edit-accumulator');
const { parseAccumulator, isPluginClonePath } = require('../../scripts/hooks/stop-format-typecheck');
const {
getPerBatchBudgetMs,
getTotalBudgetMs,
isPluginClonePath,
parseAccumulator,
} = require('../../scripts/hooks/stop-format-typecheck');
function test(name, fn) {
try {
@@ -203,6 +208,27 @@ if (test('parseAccumulator ignores blank lines and trims whitespace', () => {
assert.deepStrictEqual(result, ['/tmp/a.ts', '/tmp/b.ts']);
})) passed++; else failed++;
if (test('getTotalBudgetMs honors a bounded dispatcher budget', () => {
assert.strictEqual(
getTotalBudgetMs({ ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS: '210000' }),
210000
);
assert.strictEqual(
getTotalBudgetMs({ ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS: '999999' }),
270000
);
assert.strictEqual(
getTotalBudgetMs({ ECC_STOP_FORMAT_TYPECHECK_BUDGET_MS: 'invalid' }),
270000
);
})) passed++; else failed++;
if (test('getPerBatchBudgetMs always returns a positive child timeout', () => {
assert.strictEqual(getPerBatchBudgetMs(210000, 2), 105000);
assert.strictEqual(getPerBatchBudgetMs(210000, 210001), 1);
assert.strictEqual(getPerBatchBudgetMs(210000, 0), 60000);
})) passed++; else failed++;
if (test('stop hook clears accumulator after processing duplicates', () => {
cleanAccumFile();
fs.writeFileSync(getAccumFile(), '/nonexistent/x.ts\n/nonexistent/x.ts\n/nonexistent/y.ts\n', 'utf8');
+1 -1
View File
@@ -260,7 +260,7 @@ function runTests() {
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json')
&& operation.strategy === 'preserve-relative-path'
)),
'Should preserve non-rule Cursor platform files'
'Developer Cursor installs should register hooks through hooks-runtime'
);
assert.ok(
plan.operations.some(operation => (
+16 -7
View File
@@ -231,9 +231,11 @@ function runTests() {
normalizedRelativePath(operation.sourceRelativePath) === '.cursor/rules/common-coding-style.md'
));
assert.ok(hooksJson, 'Should preserve non-rule Cursor platform config files');
assert.strictEqual(hooksJson.strategy, 'preserve-relative-path');
assert.strictEqual(hooksJson.destinationPath, path.join(projectRoot, '.cursor', 'hooks.json'));
assert.strictEqual(
hooksJson,
undefined,
'Platform configs should not register hooks without hooks-runtime'
);
assert.ok(mcpJson, 'Should materialize a Cursor MCP config from the shared root MCP config');
assert.strictEqual(mcpJson.kind, 'merge-json');
assert.strictEqual(mcpJson.strategy, 'merge-json');
@@ -395,11 +397,10 @@ function runTests() {
'Should not preserve .md Cursor platform rule files'
);
assert.ok(
plan.operations.some(operation => (
normalizedRelativePath(operation.sourceRelativePath) === '.cursor/hooks.json'
&& operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json')
!plan.operations.some(operation => (
operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json')
)),
'Should preserve non-rule Cursor platform config files'
'Should not register Cursor hooks without hooks-runtime'
);
assert.ok(
plan.operations.some(operation => (
@@ -479,6 +480,14 @@ function runTests() {
'.cursor/hooks',
'Should prefer native Cursor hooks over generic hooks-runtime hooks'
);
const hooksConfig = plan.operations.find(operation => (
operation.destinationPath === path.join(projectRoot, '.cursor', 'hooks.json')
));
assert.strictEqual(
normalizedRelativePath(hooksConfig.sourceRelativePath),
'.cursor/hooks.json',
'Should register the native Cursor hook config through hooks-runtime'
);
})) passed++; else failed++;
if (test('plans native Antigravity 2.0 rules, workflows, skills, and agents', () => {
+35 -2
View File
@@ -255,6 +255,38 @@ function runTests() {
const mcpConfig = readJson(path.join(projectDir, '.cursor', 'mcp.json'));
assert.strictEqual(hooksConfig.version, 1);
assert.ok(hooksConfig.hooks.sessionStart, 'Should keep Cursor sessionStart hooks');
assert.strictEqual(
hooksConfig.hooks.sessionStart[0].command,
'node .cursor/hooks/session-start.js',
'Cursor sessionStart should use the event-level dispatcher'
);
for (const [eventName, entries] of Object.entries(hooksConfig.hooks)) {
assert.strictEqual(
entries.length,
1,
`Installed Cursor event ${eventName} should use one dispatcher`
);
}
const installedShellDispatcher = path.join(
projectDir,
'.cursor',
'hooks',
'before-shell-execution.js'
);
const blocked = spawnSync(process.execPath, [installedShellDispatcher], {
cwd: projectDir,
env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir },
input: JSON.stringify({ command: 'git commit --no-verify -m test' }),
encoding: 'utf8',
timeout: DEFAULT_INSTALL_APPLY_TIMEOUT_MS,
});
assert.strictEqual(
blocked.status,
2,
`Installed Cursor dispatcher should preserve blocking exit codes: ${blocked.stderr}`
);
assert.match(blocked.stderr, /BLOCKED/);
assert.ok(mcpConfig.mcpServers['chrome-devtools'], 'Should install shared MCP servers into Cursor');
const statePath = path.join(projectDir, '.cursor', 'ecc-install-state.json');
@@ -931,12 +963,12 @@ function runTests() {
const projectDir = createTempDir('install-apply-project-');
try {
const result = run(['--target', 'cursor', '--modules', 'platform-configs', '--enable-hooks'], {
const result = run(['--target', 'cursor', '--modules', 'platform-configs'], {
cwd: projectDir,
homeDir,
});
assert.strictEqual(result.code, 0, result.stderr);
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json')));
assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.mdc')));
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-agents.md')));
@@ -947,6 +979,7 @@ function runTests() {
assert.deepStrictEqual(state.request.excludeComponents, []);
assert.strictEqual(state.request.legacyMode, false);
assert.ok(state.resolution.selectedModules.includes('platform-configs'));
assert.ok(!fs.existsSync(path.join(projectDir, '.cursor', 'hooks')));
assert.ok(
!state.operations.some(operation => operation.destinationPath.endsWith('ecc-install-state.json')),
'Manifest copy operations should not include generated install-state files'