mirror of
https://github.com/Jeuners/ECC.git
synced 2026-09-09 15:02:30 +02:00
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:
parent
754b8dd76c
commit
0071fa5c3c
10 changed files with 900 additions and 161 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
114
hooks/hooks.json
114
hooks/hooks.json
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
294
scripts/hooks/posttooluse-dispatcher.js
Normal file
294
scripts/hooks/posttooluse-dispatcher.js
Normal 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
|
||||
};
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
465
tests/hooks/posttooluse-dispatcher.test.js
Normal file
465
tests/hooks/posttooluse-dispatcher.test.js
Normal 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();
|
||||
|
|
@ -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++;
|
||||
|
||||
// ==========================================
|
||||
|
|
|
|||
|
|
@ -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-');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue