refactor(hooks): consolidate PostToolUse hooks into sync/async dispatchers (#2494)

* refactor(hooks): consolidate PostToolUse hooks into sync/async dispatchers

Replace 10 individual PostToolUse entries in hooks.json with two
consolidated dispatcher entries (post:dispatcher:sync /
post:dispatcher:async). The dispatcher's internal registry preserves
every hook ID, matcher, and profile, so ECC_DISABLED_HOOKS and
ECC_HOOK_PROFILE gating behave exactly as before.

Performance (Edit event, actual hooks.json commands spawned in
parallel like the harness does, median of 7 runs):
- Blocking hook latency: 81ms -> 49ms (~40% faster; 7 blocking
  processes -> 1 sync dispatcher)
- Node processes per tool call: 10 -> 2 (7 blocking + 3 async
  -> 1 sync + 1 async)
- observe-runner now runs in-process (~370ms) inside the async
  dispatcher, which stays backgrounded (async: true, timeout 45s),
  so it adds no user-facing latency.

Also:
- dashboard-web lists dispatcher-managed child hooks so the hook
  inventory stays complete
- post-edit-console-warn refactored to export run() for in-process
  dispatch while keeping standalone stdin behavior
- dispatcher stdin reading is multi-byte safe (StringDecoder) and
  child hook exit codes propagate to the dispatcher exit code

* test(hooks): replace emoji literal with unicode escape for CI unicode safety check

* fix(hooks): adopt explicit cli() entrypoint and merge multi-hook stdout

Address Greptile review on #2494:

- Replace the non-standard 'require.main === undefined' guard with an
  explicit exported cli(). The hooks.json bootstraps now call
  require(s).cli(), so merely requiring the module (dashboard-web,
  test runners, Jest, worker threads) can never trigger dispatch,
  attach stdin listeners, or set process.exitCode.
- Replace last-writer-wins stdout with mergeHookStdout(): when several
  hooks emit additionalContext envelopes they merge into a single
  PostToolUse envelope; non-mergeable raw stdout keeps the last hook's
  output and emits a stderr warning naming the dropped hook IDs, so
  nothing is lost silently.

Also includes local formatter reformatting of the dispatcher and its
test file (no behavioral changes beyond the above).

* fix(hooks): keep post:bash:dispatcher phase reachable in minimal profile

The Greptile P1 premise was partially incorrect: sub-hooks without
explicit profiles default to standard,strict via parseProfiles()
(scripts/lib/hook-flags.js), so audit/cost logs never ran under the
minimal profile on main either — there is no user-visible regression.

However, main did spawn the bash dispatcher phase unconditionally and
let each sub-hook gate itself. Restore that semantic by opening the
outer registry gate to minimal,standard,strict so a future sub-hook
that opts into minimal is not silently blocked at the phase level.
Adds the previously missing minimal-profile async dry-run test.

* test(hooks): assert failing hook exit code propagates to real process status

Spawns the actual dispatcher subprocess with an injected failing hook
and asserts the OS-level exit status, stderr diagnostic, and suppressed
pass-through — closing the E2E gap CodeRabbit flagged on #2494.

* chore: retrigger CI (flaky windows powershell bootstrap test)
This commit is contained in:
JongHyeok Park 2026-07-20 04:47:10 +09:00 committed by GitHub
parent 754b8dd76c
commit 0071fa5c3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 900 additions and 161 deletions

View file

@ -39,8 +39,9 @@ Then report formatter findings and concrete remediation steps.
## Notes
Hook wiring lives in `hooks/hooks.json` (`post:quality-gate`, profiles
`standard`/`strict` via `run-with-flags.js`).
Hook wiring enters through the async PostToolUse dispatcher in
`hooks/hooks.json`. Its internal registry preserves the `post:quality-gate`
ID and the `standard`/`strict` profiles.
## Arguments

View file

@ -135,125 +135,29 @@
],
"PostToolUse": [
{
"matcher": "Bash",
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/post-bash-dispatcher.js",
"async": true,
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/posttooluse-dispatcher.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.env.ECC_POSTTOOLUSE_PASSTHROUGH='1';process.argv.splice(1,0,s);require(s).cli()\" sync",
"timeout": 30
}
],
"description": "Consolidated Bash postflight dispatcher for logging, PR, and build notifications",
"id": "post:bash:dispatcher"
"description": "Run synchronous PostToolUse hooks in one process while preserving per-hook controls",
"id": "post:dispatcher:sync"
},
{
"matcher": "Edit|Write|MultiEdit",
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:quality-gate scripts/hooks/quality-gate.js standard,strict",
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/posttooluse-dispatcher.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.env.ECC_POSTTOOLUSE_PASSTHROUGH='1';process.argv.splice(1,0,s);require(s).cli()\" async",
"async": true,
"timeout": 30
"timeout": 45
}
],
"description": "Run quality gate checks after file edits",
"id": "post:quality-gate"
},
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:edit:design-quality-check scripts/hooks/design-quality-check.js standard,strict",
"timeout": 10
}
],
"description": "Warn when frontend edits drift toward generic template-looking UI",
"id": "post:edit:design-quality-check"
},
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:edit:accumulate scripts/hooks/post-edit-accumulator.js standard,strict"
}
],
"description": "Record edited JS/TS file paths for batch format+typecheck at Stop time",
"id": "post:edit:accumulator"
},
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:edit:console-warn scripts/hooks/post-edit-console-warn.js standard,strict"
}
],
"description": "Warn about console.log statements after edits",
"id": "post:edit:console-warn"
},
{
"matcher": "Bash|Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:governance-capture scripts/hooks/governance-capture.js standard,strict",
"timeout": 10
}
],
"description": "Capture governance events from tool outputs. Enable with ECC_GOVERNANCE_CAPTURE=1",
"id": "post:governance-capture"
},
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:session-activity-tracker scripts/hooks/session-activity-tracker.js standard,strict",
"timeout": 10
}
],
"description": "Track per-session tool calls and file activity for ECC2 metrics",
"id": "post:session-activity-tracker"
},
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:observe scripts/hooks/observe-runner.js standard,strict",
"async": true,
"timeout": 10
}
],
"description": "Capture tool use results for continuous learning",
"id": "post:observe:continuous-learning"
},
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:ecc-metrics-bridge scripts/hooks/ecc-metrics-bridge.js minimal,standard,strict",
"timeout": 10
}
],
"description": "Maintain running session metrics aggregate for statusline and context monitor",
"id": "post:ecc-metrics-bridge"
},
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js post:ecc-context-monitor scripts/hooks/ecc-context-monitor.js standard,strict",
"timeout": 10
}
],
"description": "Inject agent warnings on context exhaustion, high cost, scope creep, or tool loops",
"id": "post:ecc-context-monitor"
"description": "Run background PostToolUse hooks in one process while preserving per-hook controls",
"id": "post:dispatcher:async"
}
],
"PostToolUseFailure": [

View file

@ -80,10 +80,41 @@ function loadMcps(_root) {
if (fs.existsSync(dir)) { for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.json'))) { try { const d = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); r.push({ f, s: Object.entries(d.mcpServers || {}).map(([k, v]) => ({ n: k, cmd: typeof v === 'object' ? (v.command || v.url || '') : String(v), args: v.args || [], env: v.env ? Object.keys(v.env).reduce((a,k)=>{a[k]='••••••'; return a;}, {}) : {}, type: v.type || 'stdio' })) }); } catch (e) { console.error('[ECC] Failed to parse mcp-configs/' + f + ':', e.message); } } }
return r;
}
function loadPostToolUseChildren(root) {
if (path.resolve(root) !== ROOT) return [];
try {
const dispatcher = require(path.join(root, 'scripts', 'hooks', 'posttooluse-dispatcher.js'));
return [
...dispatcher.SYNC_HOOKS.map(hook => ({ ...hook, mode: 'sync' })),
...dispatcher.ASYNC_HOOKS.map(hook => ({ ...hook, mode: 'async' })),
].map(hook => ({
ev: 'PostToolUse',
m: hook.matcher,
id: hook.id,
d: `Managed by the consolidated PostToolUse ${hook.mode} dispatcher`,
}));
} catch (error) {
console.error('[ECC] Failed to load PostToolUse dispatcher registry:', error.message);
return [];
}
}
function loadHooks(_root) {
const root = _root || ROOT;
const p = path.join(root, 'hooks', 'hooks.json'); if (!fs.existsSync(p)) return [];
try { const d = JSON.parse(fs.readFileSync(p, 'utf8')); const h = []; for (const [ev, es] of Object.entries(d.hooks || {})) for (const e of es || []) h.push({ ev, m: e.matcher || '*', id: e.id || '', d: e.description || '' }); return h; } catch (e) { console.error('[ECC] Failed to parse hooks/hooks.json:', e.message); return []; }
const hooksPath = path.join(root, 'hooks', 'hooks.json');
if (!fs.existsSync(hooksPath)) return [];
try {
const data = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
const hooks = [];
for (const [eventName, entries] of Object.entries(data.hooks || {})) {
for (const entry of entries || []) {
hooks.push({ ev: eventName, m: entry.matcher || '*', id: entry.id || '', d: entry.description || '' });
}
}
return [...hooks, ...loadPostToolUseChildren(root)];
} catch (error) {
console.error('[ECC] Failed to parse hooks/hooks.json:', error.message);
return [];
}
}
const LANG = {

View file

@ -12,43 +12,54 @@
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', () => {
function run(data) {
const warnings = [];
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 = [];
if (content) {
const matches = content
.split('\n')
.map((line, index) => ({ line, index }))
.filter(item => /console\.log/.test(item.line))
.map(item => `${item.index + 1}: ${item.line.trim()}`);
lines.forEach((line, idx) => {
if (/console\.log/.test(line)) {
matches.push((idx + 1) + ': ' + line.trim());
if (matches.length > 0) {
warnings.push(`[Hook] WARNING: console.log found in ${filePath}`);
warnings.push(...matches.slice(0, 5));
warnings.push('[Hook] Remove console.log before committing');
}
});
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);
});
return {
stdout: data,
stderr: warnings.join('\n'),
exitCode: 0,
};
}
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 = run(data);
if (result.stderr) process.stderr.write(`${result.stderr}\n`);
process.stdout.write(result.stdout);
process.exitCode = result.exitCode;
});
}
module.exports = { run };

View file

@ -0,0 +1,294 @@
#!/usr/bin/env node
/**
* Consolidates PostToolUse hooks into one synchronous and one asynchronous
* entrypoint while preserving each hook's ID, matcher, profile, and output.
*/
'use strict';
const path = require('path');
const { StringDecoder } = require('string_decoder');
const { VALID_PROFILES, normalizeId, parseProfiles } = require('../lib/hook-flags');
const { runPostBash } = require('./bash-hook-dispatcher');
const { run: runQualityGate } = require('./quality-gate');
const { run: runDesignQualityCheck } = require('./design-quality-check');
const { run: runPostEditAccumulator } = require('./post-edit-accumulator');
const { run: runConsoleWarn } = require('./post-edit-console-warn');
const { run: runGovernanceCapture } = require('./governance-capture');
const { run: runSessionActivityTracker } = require('./session-activity-tracker');
const { run: runObserve } = require('./observe-runner');
const { run: runMetricsBridge } = require('./ecc-metrics-bridge');
const { run: runContextMonitor } = require('./ecc-context-monitor');
const MAX_STDIN = 1024 * 1024;
const SYNC_HOOKS = [
{ id: 'post:edit:design-quality-check', matcher: 'Edit|Write|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/design-quality-check.js', run: runDesignQualityCheck },
{ id: 'post:edit:accumulator', matcher: 'Edit|Write|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/post-edit-accumulator.js', run: runPostEditAccumulator },
{ id: 'post:edit:console-warn', matcher: 'Edit', profiles: 'standard,strict', script: 'scripts/hooks/post-edit-console-warn.js', run: runConsoleWarn },
{ id: 'post:governance-capture', matcher: 'Bash|Write|Edit|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/governance-capture.js', run: runGovernanceCapture },
{ id: 'post:session-activity-tracker', matcher: '*', profiles: 'standard,strict', script: 'scripts/hooks/session-activity-tracker.js', run: runSessionActivityTracker },
{ id: 'post:ecc-metrics-bridge', matcher: '*', profiles: 'minimal,standard,strict', script: 'scripts/hooks/ecc-metrics-bridge.js', run: runMetricsBridge },
{ id: 'post:ecc-context-monitor', matcher: '*', profiles: 'standard,strict', script: 'scripts/hooks/ecc-context-monitor.js', run: runContextMonitor }
];
const ASYNC_HOOKS = [
{
id: 'post:bash:dispatcher',
matcher: 'Bash',
// main ran this phase unconditionally; sub-hooks gate themselves internally
profiles: 'minimal,standard,strict',
script: 'scripts/hooks/post-bash-dispatcher.js',
run(raw) {
const result = runPostBash(raw);
return { stdout: result.output, stderr: result.stderr, exitCode: result.exitCode };
}
},
{ id: 'post:quality-gate', matcher: 'Edit|Write|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/quality-gate.js', run: runQualityGate },
{ id: 'post:observe:continuous-learning', matcher: '*', profiles: 'standard,strict', script: 'scripts/hooks/observe-runner.js', run: runObserve }
];
function getPluginRoot(env = process.env) {
return env.CLAUDE_PLUGIN_ROOT || env.ECC_PLUGIN_ROOT || path.resolve(__dirname, '..', '..');
}
function matchesTool(matcher, toolName) {
return (
matcher === '*' ||
String(matcher || '')
.split('|')
.map(value => value.trim())
.filter(Boolean)
.includes(String(toolName || ''))
);
}
function isEnabled(hook, env) {
const disabled = new Set(
String(env.ECC_DISABLED_HOOKS || '')
.split(',')
.map(normalizeId)
.filter(Boolean)
);
const requestedProfile = String(env.ECC_HOOK_PROFILE || 'standard')
.trim()
.toLowerCase();
const profile = VALID_PROFILES.has(requestedProfile) ? requestedProfile : 'standard';
return !disabled.has(normalizeId(hook.id)) && parseProfiles(hook.profiles).includes(profile);
}
function extractToolName(raw) {
try {
return String(JSON.parse(raw)?.tool_name || '');
} catch {
return '';
}
}
function buildDryRunPreview(hook, raw) {
let target = '';
try {
const input = JSON.parse(raw)?.tool_input || {};
target = String(input.file_path || input.path || input.command || '');
} catch {
target = '';
}
const suffix = target ? ` target=${target}` : '';
return `[DryRun] Hook "${hook.id}" would execute: ${hook.script} (enabled=true, profiles=${hook.profiles})${suffix}\n`;
}
function normalizeResult(raw, output) {
if (typeof output === 'string' || Buffer.isBuffer(output)) {
const stdout = String(output);
return { stdout: stdout !== raw ? stdout : '', stderr: '', exitCode: 0 };
}
if (!output || typeof output !== 'object') {
return { stdout: '', stderr: '', exitCode: 0 };
}
let stdout = '';
if (Object.prototype.hasOwnProperty.call(output, 'stdout')) {
stdout = String(output.stdout ?? '');
} else if (Object.prototype.hasOwnProperty.call(output, 'output')) {
stdout = String(output.output ?? '');
} else if (Object.prototype.hasOwnProperty.call(output, 'additionalContext')) {
stdout = JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PostToolUse',
additionalContext: String(output.additionalContext ?? '')
}
});
}
return {
stdout: stdout !== raw ? stdout : '',
stderr: typeof output.stderr === 'string' ? output.stderr : '',
exitCode: Number.isInteger(output.exitCode) ? output.exitCode : 0
};
}
function appendLine(current, next) {
if (!next) return current;
return current + (String(next).endsWith('\n') ? String(next) : `${next}\n`);
}
function parseAdditionalContext(stdout) {
try {
const parsed = JSON.parse(stdout);
const output = parsed?.hookSpecificOutput;
if (output?.hookEventName !== 'PostToolUse') return null;
return typeof output.additionalContext === 'string' ? output.additionalContext : null;
} catch {
return null;
}
}
function mergeHookStdout(outputs) {
if (outputs.length === 0) return { stdout: '', warning: '' };
if (outputs.length === 1) return { stdout: outputs[0].stdout, warning: '' };
const contexts = outputs.map(output => parseAdditionalContext(output.stdout));
if (contexts.every(context => context !== null)) {
return {
stdout: JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PostToolUse',
additionalContext: contexts.join('\n')
}
}),
warning: ''
};
}
const kept = outputs[outputs.length - 1];
const dropped = outputs
.slice(0, -1)
.map(output => output.id)
.join(', ');
return {
stdout: kept.stdout,
warning: `[Hook] stdout from ${dropped} dropped in favor of ${kept.id}; raw stdout cannot be merged`
};
}
function runHooks(raw, hooks, options = {}) {
const env = options.env || process.env;
const toolName = options.toolName ?? extractToolName(raw);
const pluginRoot = getPluginRoot(env);
const outputs = [];
let stderr = '';
let exitCode = 0;
for (const hook of hooks) {
if (!matchesTool(hook.matcher, toolName) || !isEnabled(hook, env)) continue;
if (env.ECC_DRY_RUN === '1') {
stderr += buildDryRunPreview(hook, raw);
continue;
}
try {
const result = normalizeResult(
raw,
hook.run(raw, {
hookId: hook.id,
pluginRoot,
scriptPath: path.join(pluginRoot, hook.script || ''),
truncated: options.truncated === true,
maxStdin: MAX_STDIN
})
);
if (result.stdout) outputs.push({ id: hook.id, stdout: result.stdout });
stderr = appendLine(stderr, result.stderr);
if (result.exitCode !== 0) {
if (exitCode === 0) exitCode = result.exitCode;
stderr = appendLine(stderr, `[Hook] ${hook.id} exited with code ${result.exitCode}; continuing`);
}
} catch (error) {
stderr = appendLine(stderr, `[Hook] ${hook.id} failed: ${error.message}`);
}
}
const merged = mergeHookStdout(outputs);
if (merged.warning) stderr = appendLine(stderr, merged.warning);
return { stdout: merged.stdout, stderr, exitCode };
}
function readStdinRaw() {
return new Promise(resolve => {
const decoder = new StringDecoder('utf8');
let raw = '';
let bytesRead = 0;
let truncated = false;
let settled = false;
process.stdin.on('data', chunk => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
const remaining = Math.max(0, MAX_STDIN - bytesRead);
const accepted = buffer.subarray(0, remaining);
if (accepted.length > 0) {
raw += decoder.write(accepted);
bytesRead += accepted.length;
}
if (buffer.length > accepted.length) truncated = true;
});
const finish = () => {
if (settled) return;
settled = true;
if (!truncated) raw += decoder.end();
resolve({ raw, truncated });
};
process.stdin.once('end', finish);
process.stdin.once('error', finish);
});
}
function resolveMainStdout(raw, result, options = {}) {
if (result.stdout) return result.stdout;
if (options.truncated || result.exitCode !== 0 || !options.passthrough) return '';
return raw;
}
async function main() {
const mode = process.argv[2] === 'async' ? 'async' : 'sync';
const { raw, truncated } = await readStdinRaw();
const dispatcherId = `post:dispatcher:${mode}`;
const dispatcherEnabled = isEnabled(
{
id: dispatcherId,
profiles: 'minimal,standard,strict'
},
process.env
);
const hooks = dispatcherEnabled ? (mode === 'async' ? ASYNC_HOOKS : SYNC_HOOKS) : [];
const result = runHooks(raw, hooks, { truncated });
if (truncated) {
process.stderr.write(`[Hook] stdin exceeded ${MAX_STDIN} bytes for PostToolUse ${mode}; suppressing pass-through\n`);
}
if (result.stderr) process.stderr.write(result.stderr);
const stdout = resolveMainStdout(raw, result, {
passthrough: process.env.ECC_POSTTOOLUSE_PASSTHROUGH === '1',
truncated
});
if (stdout) process.stdout.write(stdout);
process.exitCode = result.exitCode;
}
function cli() {
main().catch(error => {
process.stderr.write(`[Hook] PostToolUse dispatcher failed: ${error.message}\n`);
process.exitCode = 0;
});
}
if (require.main === module) cli();
module.exports = {
ASYNC_HOOKS,
SYNC_HOOKS,
cli,
matchesTool,
main,
mergeHookStdout,
normalizeResult,
resolveMainStdout,
runHooks
};

View file

@ -16,6 +16,7 @@ const repoRoot = path.resolve(__dirname, '..', '..');
const hooksJsonPath = path.join(repoRoot, 'hooks', 'hooks.json');
const runWithFlagsPath = path.join(repoRoot, 'scripts', 'hooks', 'run-with-flags.js');
const observeRunner = require(path.join(repoRoot, 'scripts', 'hooks', 'observe-runner.js'));
const postToolUseDispatcher = require(path.join(repoRoot, 'scripts', 'hooks', 'posttooluse-dispatcher.js'));
function test(name, fn) {
try {
@ -115,14 +116,14 @@ function runTests() {
let failed = 0;
if (test('observe hooks use node-mode runner instead of shell-mode dispatch', () => {
for (const hookId of ['pre:observe:continuous-learning', 'post:observe:continuous-learning']) {
const command = loadHook(hookId);
const phase = hookId.startsWith('pre:') ? 'pre:observe' : 'post:observe';
const preCommand = loadHook('pre:observe:continuous-learning');
assert.ok(preCommand.includes('node scripts/hooks/run-with-flags.js pre:observe scripts/hooks/observe-runner.js standard,strict'));
assert.ok(!preCommand.includes('shell scripts/hooks/run-with-flags-shell.sh'));
assert.ok(!preCommand.includes('skills/continuous-learning-v2/hooks/observe.sh'));
assert.ok(command.includes(`node scripts/hooks/run-with-flags.js ${phase} scripts/hooks/observe-runner.js standard,strict`));
assert.ok(!command.includes('shell scripts/hooks/run-with-flags-shell.sh'), `${hookId} should not use shell-mode bootstrap`);
assert.ok(!command.includes('skills/continuous-learning-v2/hooks/observe.sh'), `${hookId} should not call observe.sh directly from hooks.json`);
}
const postHook = postToolUseDispatcher.ASYNC_HOOKS.find(hook => hook.id === 'post:observe:continuous-learning');
assert.ok(postHook, 'PostToolUse dispatcher should retain the observe hook ID');
assert.strictEqual(postHook.script, 'scripts/hooks/observe-runner.js');
})) passed++; else failed++;
if (test('run-with-flags passes hookId to direct run exports', () => {

View file

@ -2476,23 +2476,29 @@ async function runTests() {
else failed++;
if (
test('hooks.json consolidates Bash hooks into one pre and one post dispatcher', () => {
test('hooks.json consolidates PreToolUse Bash and all PostToolUse hooks', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
const preBash = hooks.hooks.PreToolUse.filter(entry => entry.matcher === 'Bash');
const postBash = hooks.hooks.PostToolUse.filter(entry => entry.matcher === 'Bash');
const postEntries = hooks.hooks.PostToolUse;
assert.strictEqual(preBash.length, 1, 'Should have exactly one PreToolUse Bash dispatcher');
assert.strictEqual(postBash.length, 1, 'Should have exactly one PostToolUse Bash dispatcher');
assert.strictEqual(preBash[0].id, 'pre:bash:dispatcher');
assert.strictEqual(postBash[0].id, 'post:bash:dispatcher');
assert.deepStrictEqual(
postEntries.map(entry => entry.id),
['post:dispatcher:sync', 'post:dispatcher:async'],
'PostToolUse should have one sync and one async dispatcher'
);
assert.ok(postEntries.every(entry => entry.matcher === '*'));
const preCommand = Array.isArray(preBash[0].hooks[0].command) ? preBash[0].hooks[0].command.join(' ') : preBash[0].hooks[0].command;
const postCommand = Array.isArray(postBash[0].hooks[0].command) ? postBash[0].hooks[0].command.join(' ') : postBash[0].hooks[0].command;
assert.ok(preCommand.includes('pre-bash-dispatcher.js'), 'PreToolUse Bash hook should use the pre dispatcher');
assert.ok(postCommand.includes('post-bash-dispatcher.js'), 'PostToolUse Bash hook should use the post dispatcher');
assert.ok(postEntries[0].hooks[0].command.includes('posttooluse-dispatcher.js'));
assert.ok(postEntries[0].hooks[0].command.endsWith('" sync'));
assert.ok(postEntries[1].hooks[0].command.includes('posttooluse-dispatcher.js'));
assert.ok(postEntries[1].hooks[0].command.endsWith('" async'));
})
)
passed++;
@ -2643,8 +2649,9 @@ async function runTests() {
if (hook.type === 'command' && commandText.includes('scripts/hooks/')) {
const usesInlineResolver = commandStart.startsWith('node -e') && commandText.includes('run-with-flags.js');
const usesPluginBootstrap = commandStart.startsWith('node -e') && commandText.includes('plugin-hook-bootstrap.js');
const usesDirectPostDispatcher = commandStart.startsWith('node -e') && commandText.includes('posttooluse-dispatcher.js') && commandText.includes('resolve-ecc-root');
assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), `Script paths should not depend on raw shell placeholder expansion: ${commandText.substring(0, 80)}...`);
assert.ok(usesInlineResolver || usesPluginBootstrap, `Script paths should use the inline resolver or plugin bootstrap: ${commandText.substring(0, 80)}...`);
assert.ok(usesInlineResolver || usesPluginBootstrap || usesDirectPostDispatcher, `Script paths should use the inline resolver or plugin bootstrap: ${commandText.substring(0, 80)}...`);
}
}
}
@ -4227,9 +4234,15 @@ async function runTests() {
console.log('\nRound 29: post-edit-console-warn.js (extension and exit):');
if (
await asyncTest('source calls process.exit(0) after writing output', async () => {
const cwSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-console-warn.js'), 'utf8');
assert.ok(cwSource.includes('process.exit(0)'), 'Should call process.exit(0)');
await asyncTest('exports a require-safe run function', async () => {
const consoleWarn = require(path.join(scriptsDir, 'post-edit-console-warn.js'));
const stdinJson = JSON.stringify({ tool_input: { file_path: '/test.py' } });
assert.strictEqual(typeof consoleWarn.run, 'function');
assert.deepStrictEqual(consoleWarn.run(stdinJson), {
stdout: stdinJson,
stderr: '',
exitCode: 0,
});
})
)
passed++;

View file

@ -0,0 +1,465 @@
/**
* Contract tests for the consolidated PostToolUse dispatchers.
*/
'use strict';
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const repoRoot = path.join(__dirname, '..', '..');
const hooksPath = path.join(repoRoot, 'hooks', 'hooks.json');
const dispatcherPath = path.join(repoRoot, 'scripts', 'hooks', 'posttooluse-dispatcher.js');
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runDispatcher(mode, toolName, env = {}) {
const raw = JSON.stringify({
hook_event_name: 'PostToolUse',
tool_name: toolName,
tool_input: toolName === 'Bash' ? { command: 'true' } : { file_path: path.join(os.tmpdir(), 'ecc-posttooluse-test.txt') },
tool_response: {}
});
return spawnSync(process.execPath, [dispatcherPath, mode], {
cwd: repoRoot,
input: raw,
encoding: 'utf8',
env: {
...process.env,
CLAUDE_PLUGIN_ROOT: repoRoot,
ECC_PLUGIN_ROOT: repoRoot,
...env
},
timeout: 10000
});
}
function previewedIds(stderr) {
return [...String(stderr).matchAll(/Hook "([^"]+)"/g)].map(match => match[1]);
}
function runConfiguredCommand(entry, raw, env = {}) {
return spawnSync(entry.hooks[0].command, {
shell: true,
cwd: repoRoot,
input: raw,
encoding: 'utf8',
env: {
...process.env,
CLAUDE_PLUGIN_ROOT: repoRoot,
ECC_PLUGIN_ROOT: repoRoot,
...env
},
timeout: 10000
});
}
function runTests() {
console.log('\n=== PostToolUse dispatcher tests ===\n');
let passed = 0;
let failed = 0;
if (
test('hooks.json exposes one sync and one async PostToolUse entry', () => {
const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse;
assert.strictEqual(entries.length, 2, 'PostToolUse should launch at most two commands');
assert.deepStrictEqual(
entries.map(entry => entry.id),
['post:dispatcher:sync', 'post:dispatcher:async']
);
assert.ok(entries.every(entry => entry.matcher === '*'));
assert.strictEqual(entries[0].hooks[0].async, undefined);
assert.strictEqual(entries[1].hooks[0].async, true);
assert.ok(entries[0].hooks[0].command.includes('posttooluse-dispatcher.js'));
assert.ok(entries[0].hooks[0].command.endsWith('" sync'));
assert.ok(entries[1].hooks[0].command.includes('posttooluse-dispatcher.js'));
assert.ok(entries[1].hooks[0].command.endsWith('" async'));
assert.ok(entries.every(entry => entry.hooks[0].command.includes('resolve-ecc-root')));
assert.ok(
entries.every(entry => !entry.hooks[0].command.includes('plugin-hook-bootstrap.js')),
'PostToolUse dispatchers should not spawn a second Node bootstrap process'
);
assert.ok(entries[1].hooks[0].timeout >= 30);
})
)
passed++;
else failed++;
if (
test('dry-run selects the original IDs by tool and phase', () => {
const cases = [
{
tool: 'Edit',
sync: [
'post:edit:design-quality-check',
'post:edit:accumulator',
'post:edit:console-warn',
'post:governance-capture',
'post:session-activity-tracker',
'post:ecc-metrics-bridge',
'post:ecc-context-monitor'
],
async: ['post:quality-gate', 'post:observe:continuous-learning']
},
{
tool: 'Write',
sync: ['post:edit:design-quality-check', 'post:edit:accumulator', 'post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'],
async: ['post:quality-gate', 'post:observe:continuous-learning']
},
{
tool: 'Bash',
sync: ['post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'],
async: ['post:bash:dispatcher', 'post:observe:continuous-learning']
},
{
tool: 'Read',
sync: ['post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'],
async: ['post:observe:continuous-learning']
}
];
for (const expected of cases) {
const sync = runDispatcher('sync', expected.tool, { ECC_DRY_RUN: '1' });
const asyncResult = runDispatcher('async', expected.tool, { ECC_DRY_RUN: '1' });
assert.strictEqual(sync.status, 0, sync.stderr);
assert.strictEqual(asyncResult.status, 0, asyncResult.stderr);
assert.deepStrictEqual(previewedIds(sync.stderr), expected.sync, `${expected.tool} sync IDs`);
assert.deepStrictEqual(previewedIds(asyncResult.stderr), expected.async, `${expected.tool} async IDs`);
assert.strictEqual(sync.stdout, '');
assert.strictEqual(asyncResult.stdout, '');
}
})
)
passed++;
else failed++;
if (
test('actual hooks.json commands preserve Edit dry-run output and IDs', () => {
const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse;
const raw = JSON.stringify({
hook_event_name: 'PostToolUse',
tool_name: 'Edit',
tool_input: { file_path: path.join(os.tmpdir(), 'ecc-posttooluse-test.txt') },
tool_response: {}
});
const results = entries.map(entry => runConfiguredCommand(entry, raw, { ECC_DRY_RUN: '1' }));
for (const result of results) {
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(result.stdout, raw, 'configured command should preserve pass-through output');
}
const ids = results.flatMap(result => previewedIds(result.stderr));
assert.deepStrictEqual(ids, [
'post:edit:design-quality-check',
'post:edit:accumulator',
'post:edit:console-warn',
'post:governance-capture',
'post:session-activity-tracker',
'post:ecc-metrics-bridge',
'post:ecc-context-monitor',
'post:quality-gate',
'post:observe:continuous-learning'
]);
})
)
passed++;
else failed++;
if (
test('actual hooks.json commands never echo truncated oversized input', () => {
const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse;
const values = ['x'.repeat(1024 * 1024 + 1024), 'é'.repeat(600000), '\u{1F600}'.repeat(300000)];
for (const value of values) {
const raw = JSON.stringify({
hook_event_name: 'PostToolUse',
tool_name: 'Read',
tool_input: { value },
tool_response: {}
});
assert.ok(Buffer.byteLength(raw, 'utf8') > 1024 * 1024);
for (const entry of entries) {
const result = runConfiguredCommand(entry, raw, { ECC_DRY_RUN: '1' });
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(result.stdout, '', `${entry.id} should suppress truncated pass-through`);
assert.ok(result.stderr.includes('stdin exceeded'), `${entry.id} should report truncation`);
}
}
})
)
passed++;
else failed++;
if (
test('profiles and disabled IDs remain scoped to each original hook', () => {
const minimalSync = runDispatcher('sync', 'Edit', {
ECC_DRY_RUN: '1',
ECC_HOOK_PROFILE: 'minimal'
});
assert.strictEqual(minimalSync.status, 0, minimalSync.stderr);
assert.deepStrictEqual(previewedIds(minimalSync.stderr), ['post:ecc-metrics-bridge']);
const minimalAsync = runDispatcher('async', 'Bash', {
ECC_DRY_RUN: '1',
ECC_HOOK_PROFILE: 'minimal'
});
assert.strictEqual(minimalAsync.status, 0, minimalAsync.stderr);
assert.deepStrictEqual(previewedIds(minimalAsync.stderr), ['post:bash:dispatcher'], 'bash dispatcher phase must stay reachable in minimal profile like main; its sub-hooks gate themselves');
const disabled = runDispatcher('sync', 'Edit', {
ECC_DRY_RUN: '1',
ECC_DISABLED_HOOKS: 'post:edit:accumulator'
});
assert.strictEqual(disabled.status, 0, disabled.stderr);
const ids = previewedIds(disabled.stderr);
assert.ok(!ids.includes('post:edit:accumulator'));
assert.ok(ids.includes('post:edit:design-quality-check'));
assert.ok(ids.includes('post:ecc-context-monitor'));
})
)
passed++;
else failed++;
if (
test('public dispatcher IDs disable their complete phase', () => {
const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse;
const raw = JSON.stringify({
hook_event_name: 'PostToolUse',
tool_name: 'Edit',
tool_input: { file_path: path.join(os.tmpdir(), 'ecc-posttooluse-test.txt') },
tool_response: {}
});
for (const entry of entries) {
const result = runConfiguredCommand(entry, raw, {
ECC_DRY_RUN: '1',
ECC_DISABLED_HOOKS: entry.id
});
assert.strictEqual(result.status, 0, result.stderr);
assert.deepStrictEqual(previewedIds(result.stderr), [], `${entry.id} should disable all child hooks`);
assert.strictEqual(result.stdout, raw);
}
})
)
passed++;
else failed++;
if (
test('dry-run has no PostToolUse side effects', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-posttooluse-dry-run-'));
try {
const result = runDispatcher('sync', 'Edit', {
ECC_DRY_RUN: '1',
HOME: homeDir,
USERPROFILE: homeDir,
CLAUDE_SESSION_ID: 'dry-run-session'
});
assert.strictEqual(result.status, 0, result.stderr);
assert.deepStrictEqual(fs.readdirSync(homeDir), []);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})
)
passed++;
else failed++;
if (
test('dispatcher isolates failures and preserves explicit output and exit status', () => {
assert.ok(fs.existsSync(dispatcherPath), 'dispatcher module should exist');
const { resolveMainStdout, runHooks } = require(dispatcherPath);
const calls = [];
const raw = JSON.stringify({ tool_name: 'Read' });
const explicitOutput = JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PostToolUse',
additionalContext: 'context warning'
}
});
const hooks = [
{
id: 'post:test:first',
matcher: '*',
profiles: 'standard,strict',
run: input => {
calls.push(['first', input]);
return input;
}
},
{
id: 'post:test:broken',
matcher: '*',
profiles: 'standard,strict',
run: () => {
throw new Error('boom');
}
},
{ id: 'post:test:nonzero', matcher: '*', profiles: 'standard,strict', run: () => ({ exitCode: 7 }) },
{
id: 'post:test:last',
matcher: '*',
profiles: 'standard,strict',
run: input => {
calls.push(['last', input]);
return { stdout: explicitOutput, stderr: 'last warning' };
}
}
];
const result = runHooks(raw, hooks, { toolName: 'Read', env: { ECC_HOOK_PROFILE: 'standard' } });
assert.deepStrictEqual(
calls,
[
['first', raw],
['last', raw]
],
'each hook should receive the original input'
);
assert.strictEqual(result.stdout, explicitOutput);
assert.ok(result.stderr.includes('post:test:broken'));
assert.ok(result.stderr.includes('boom'));
assert.ok(result.stderr.includes('post:test:nonzero'));
assert.ok(result.stderr.indexOf('post:test:broken') < result.stderr.indexOf('last warning'));
assert.strictEqual(result.exitCode, 7, 'explicit child exit codes should be preserved');
assert.strictEqual(resolveMainStdout(raw, { stdout: '', exitCode: 7 }, { passthrough: true, truncated: false }), '', 'nonzero results should not restore raw input');
})
)
passed++;
else failed++;
if (
test('failing hook exit code propagates to the real dispatcher process status', () => {
const script = [
`const dispatcher = require(${JSON.stringify(dispatcherPath)});`,
'dispatcher.SYNC_HOOKS.length = 0;',
"dispatcher.SYNC_HOOKS.push({ id: 'post:test:fail', matcher: '*', profiles: 'standard,strict', run: () => ({ exitCode: 7 }) });",
"process.argv[2] = 'sync';",
'dispatcher.cli();'
].join('');
const result = spawnSync(process.execPath, ['-e', script], {
cwd: repoRoot,
input: JSON.stringify({ hook_event_name: 'PostToolUse', tool_name: 'Read', tool_input: {}, tool_response: {} }),
encoding: 'utf8',
env: { ...process.env, CLAUDE_PLUGIN_ROOT: repoRoot, ECC_POSTTOOLUSE_PASSTHROUGH: '1' },
timeout: 10000
});
assert.strictEqual(result.status, 7, 'OS-level exit status should reflect the failing hook');
assert.ok(result.stderr.includes('post:test:fail exited with code 7'), result.stderr);
assert.strictEqual(result.stdout, '', 'failed runs must not restore pass-through output');
})
)
passed++;
else failed++;
if (
test('console warning hook is safe to require in-process', () => {
const script = [`const hook = require(${JSON.stringify(path.join(repoRoot, 'scripts', 'hooks', 'post-edit-console-warn.js'))});`, "if (typeof hook.run !== 'function') process.exit(2);"].join(
''
);
const result = spawnSync(process.execPath, ['-e', script], { encoding: 'utf8', timeout: 5000 });
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(result.stdout, '');
})
)
passed++;
else failed++;
if (
test('empty and malformed input fail open', () => {
for (const input of ['', '{not-json']) {
const result = spawnSync(process.execPath, [dispatcherPath, 'sync'], {
cwd: repoRoot,
input,
encoding: 'utf8',
env: { ...process.env, CLAUDE_PLUGIN_ROOT: repoRoot },
timeout: 10000
});
assert.strictEqual(result.status, 0, result.stderr);
}
})
)
passed++;
else failed++;
if (
test('multiple additionalContext outputs merge; raw stdout conflicts warn', () => {
const { mergeHookStdout, runHooks } = require(dispatcherPath);
const envelope = context =>
JSON.stringify({
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: context }
});
const contextHook = (id, context) => ({
id,
matcher: '*',
profiles: 'standard,strict',
run: () => ({ additionalContext: context })
});
const merged = runHooks(JSON.stringify({ tool_name: 'Read' }), [contextHook('post:test:one', 'first warning'), contextHook('post:test:two', 'second warning')], {
toolName: 'Read',
env: { ECC_HOOK_PROFILE: 'standard' }
});
assert.strictEqual(merged.stdout, envelope('first warning\nsecond warning'), 'context envelopes should merge into one');
assert.ok(!merged.stderr.includes('dropped'), merged.stderr);
const conflicting = mergeHookStdout([
{ id: 'post:test:raw', stdout: 'plain output' },
{ id: 'post:test:ctx', stdout: envelope('kept warning') }
]);
assert.strictEqual(conflicting.stdout, envelope('kept warning'), 'last output should win when raw stdout cannot merge');
assert.ok(conflicting.warning.includes('post:test:raw'), 'dropped hook IDs should be named');
assert.ok(conflicting.warning.includes('post:test:ctx'));
})
)
passed++;
else failed++;
if (
test('requiring the dispatcher module never dispatches; hooks.json calls cli()', () => {
const raw = JSON.stringify({
hook_event_name: 'PostToolUse',
tool_name: 'Read',
tool_input: {},
tool_response: {}
});
const result = spawnSync(process.execPath, ['-e', `require(${JSON.stringify(dispatcherPath)})`], {
cwd: repoRoot,
input: raw,
encoding: 'utf8',
env: { ...process.env, CLAUDE_PLUGIN_ROOT: repoRoot, ECC_POSTTOOLUSE_PASSTHROUGH: '1' },
timeout: 10000
});
assert.strictEqual(result.status, 0, result.stderr);
assert.strictEqual(result.stdout, '', 'require() alone must not run main() or echo stdin');
const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse;
assert.ok(
entries.every(entry => entry.hooks[0].command.includes('require(s).cli()')),
'hooks.json must invoke the explicit cli() entrypoint'
);
})
)
passed++;
else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();

View file

@ -675,16 +675,23 @@ async function runTests() {
})) passed++; else failed++;
if (await asyncTest('PostToolUse PR hook extracts PR URL', async () => {
const hookCommand = getHookCommandById(hooks, 'PostToolUse', 'post:bash:dispatcher');
const result = await runHookCommand(hookCommand, {
tool_input: { command: 'gh pr create --title "Test"' },
tool_output: { output: 'Creating pull request...\nhttps://github.com/owner/repo/pull/123' }
});
const hookCommand = getHookCommandById(hooks, 'PostToolUse', 'post:dispatcher:async');
const testDir = createTestDir();
try {
const result = await runHookCommand(hookCommand, {
hook_event_name: 'PostToolUse',
tool_name: 'Bash',
tool_input: { command: 'gh pr create --title "Test"' },
tool_output: { output: 'Creating pull request...\nhttps://github.com/owner/repo/pull/123' }
}, { HOME: testDir, USERPROFILE: testDir });
assert.ok(
result.stderr.includes('PR created') || result.stderr.includes('github.com'),
'Should extract and log PR URL'
);
assert.ok(
result.stderr.includes('PR created') || result.stderr.includes('github.com'),
'Should extract and log PR URL'
);
} finally {
cleanupTestDir(testDir);
}
})) passed++; else failed++;
// ==========================================

View file

@ -540,6 +540,18 @@ test('loadHooks loads hook definitions', () => {
cleanup(testRoot);
});
test('loadHooks exposes consolidated PostToolUse child IDs', () => {
const { loadHooks } = require(SCRIPT);
const repoRoot = path.join(__dirname, '..', '..');
const hooks = loadHooks(repoRoot);
assert.ok(hooks.some(hook => hook.id === 'post:dispatcher:sync'));
assert.ok(hooks.some(hook => hook.id === 'post:dispatcher:async'));
assert.ok(hooks.some(hook => hook.id === 'post:quality-gate'));
assert.ok(hooks.some(hook => hook.id === 'post:edit:accumulator'));
assert.ok(hooks.some(hook => hook.id === 'post:ecc-context-monitor'));
});
test('loadHooks handles malformed JSON gracefully', () => {
const { loadHooks } = require(SCRIPT);
testRoot = createTempDir('ecc-test-');