fix(hooks): keep hooks.json within Claude Code's schema

Move stable hook metadata to a validated sidecar while preserving hook commands and installer identity. Reject moved fingerprints and duplicate IDs, and validate before updating metadata. Independent local review passed at c315271624a1fd055b992f2bff889ad2a0ff8a6b; CI run 34678210149 passed. Rollback: revert this squash commit.
This commit is contained in:
zpearce-2814
2026-09-12 02:59:02 -04:00
committed by GitHub
parent c4904e3f63
commit 1ac07903ec
19 changed files with 1060 additions and 104 deletions
+4
View File
@@ -19,6 +19,10 @@ User request → Claude picks a tool → PreToolUse hook runs → Tool executes
Memory persistence lifecycle definitions live in `hooks/memory-persistence/`.
The executable hook graph remains `hooks/hooks.json`; the memory persistence directory is the stable contract for SessionStart, PreCompact, observation, activity tracking, and SessionEnd behavior.
Stable hook IDs and descriptions live in `hooks/hooks.metadata.json`, aligned by event and index with `hooks/hooks.json`. Claude Code validates a plugin's `hooks.json` against its own schema and reports any other key (`$schema`, `id`, `description`) as unknown at load time, so `hooks.json` carries only what the harness accepts. ECC's installer, validator, and dashboard merge the sidecar back in through `scripts/lib/hooks-config.js`; `node scripts/ci/validate-hooks.js` fails if the two files drift apart.
Each sidecar entry also carries a `fingerprint` of the matcher entry it describes (matcher plus hook commands), so reordering `hooks.json` without reordering the sidecar, or editing a command without updating the sidecar, is caught rather than silently swapping IDs. When reordering hooks, move the matching sidecar entries first. Then run `node scripts/ci/validate-hooks.js --update-fingerprints` to refresh changed commands and commit both files. The updater rejects known fingerprints at different positions and writes only after validation succeeds.
## Installing These Hooks Manually
For Claude Code manual installs, do not paste the raw repo `hooks.json` into `~/.claude/settings.json` or copy it directly into `~/.claude/hooks/hooks.json`. The checked-in file is plugin/repo-oriented and is meant to be installed through the ECC installer or loaded as a plugin.
+24 -73
View File
@@ -1,5 +1,4 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"hooks": {
"PreToolUse": [
{
@@ -9,9 +8,7 @@
"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/pre-bash-dispatcher.js"
}
],
"description": "Consolidated Bash preflight dispatcher for quality, tmux, push, and GateGuard checks",
"id": "pre:bash:dispatcher"
]
},
{
"matcher": "PowerShell",
@@ -21,9 +18,7 @@
"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 pre:powershell:gateguard-fact-force scripts/hooks/gateguard-fact-force.js standard,strict",
"timeout": 5
}
],
"description": "PowerShell fact-forcing gate: inspect destructive commands without running unrelated Bash-only preflight hooks",
"id": "pre:powershell:gateguard-fact-force"
]
},
{
"matcher": "Write",
@@ -32,9 +27,7 @@
"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 pre:write:doc-file-warning scripts/hooks/doc-file-warning.js standard,strict"
}
],
"description": "Doc file warning: warn about non-standard documentation files (exit code 0; warns only)",
"id": "pre:write:doc-file-warning"
]
},
{
"matcher": "Edit|Write",
@@ -43,9 +36,7 @@
"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 pre:edit-write:suggest-compact scripts/hooks/suggest-compact.js standard,strict"
}
],
"description": "Suggest manual compaction at logical intervals",
"id": "pre:edit-write:suggest-compact"
]
},
{
"matcher": ".*",
@@ -56,9 +47,7 @@
"async": true,
"timeout": 10
}
],
"description": "Capture tool use observations for continuous learning",
"id": "pre:observe:continuous-learning"
]
},
{
"matcher": "Bash|PowerShell|Write|Edit|MultiEdit",
@@ -68,9 +57,7 @@
"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 pre:governance-capture scripts/hooks/governance-capture.js standard,strict",
"timeout": 10
}
],
"description": "Capture governance events (secrets, policy violations, approval requests). Enable with ECC_GOVERNANCE_CAPTURE=1",
"id": "pre:governance-capture"
]
},
{
"matcher": "Write|Edit|MultiEdit",
@@ -80,9 +67,7 @@
"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 pre:config-protection scripts/hooks/config-protection.js standard,strict",
"timeout": 5
}
],
"description": "Block modifications to linter/formatter config files. Steers agent to fix code instead of weakening configs.",
"id": "pre:config-protection"
]
},
{
"matcher": ".*",
@@ -91,9 +76,7 @@
"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 pre:mcp-health-check scripts/hooks/mcp-health-check.js standard,strict"
}
],
"description": "Check MCP server health before MCP tool execution and block unhealthy MCP calls",
"id": "pre:mcp-health-check"
]
},
{
"matcher": "Edit|Write|MultiEdit",
@@ -103,9 +86,7 @@
"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 pre:edit-write:gateguard-fact-force scripts/hooks/gateguard-fact-force.js standard,strict",
"timeout": 5
}
],
"description": "Fact-forcing gate: block first Edit/Write/MultiEdit per file and demand investigation (importers, data schemas, user instruction) before allowing",
"id": "pre:edit-write:gateguard-fact-force"
]
}
],
"PreCompact": [
@@ -116,9 +97,7 @@
"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 pre:compact scripts/hooks/pre-compact.js standard,strict"
}
],
"description": "Save state before context compaction",
"id": "pre:compact"
]
}
],
"SessionStart": [
@@ -129,9 +108,7 @@
"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/session-start-bootstrap.js"
}
],
"description": "Load previous context and detect package manager on new session",
"id": "session:start"
]
},
{
"matcher": ".*",
@@ -140,9 +117,7 @@
"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 session-start:plan-canvas-sessions scripts/hooks/plan-canvas-sessions.js standard,strict"
}
],
"description": "Surface open Plan Canvas review sessions so a fresh session can resume the loop",
"id": "session-start:plan-canvas-sessions"
]
}
],
"PostToolUse": [
@@ -154,9 +129,7 @@
"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": "Run synchronous PostToolUse hooks in one process while preserving per-hook controls",
"id": "post:dispatcher:sync"
]
},
{
"matcher": ".*",
@@ -167,9 +140,7 @@
"async": true,
"timeout": 45
}
],
"description": "Run background PostToolUse hooks in one process while preserving per-hook controls",
"id": "post:dispatcher:async"
]
}
],
"PostToolUseFailure": [
@@ -180,9 +151,7 @@
"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:mcp-health-check scripts/hooks/mcp-health-check.js standard,strict"
}
],
"description": "Track failed MCP tool calls, mark unhealthy servers, and attempt reconnect",
"id": "post:mcp-health-check"
]
},
{
"matcher": "Skill",
@@ -191,9 +160,7 @@
"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:skill:track scripts/hooks/skill-run-tracker.js standard,strict"
}
],
"description": "Record hard Skill tool failures for skill-health telemetry",
"id": "post:skill:track"
]
}
],
"Stop": [
@@ -204,9 +171,7 @@
"type": "command",
"command": "node -e \"const fs=require('fs');const path=require('path');const {spawnSync}=require('child_process');const raw=fs.readFileSync(0,'utf8');const finish=(out,err,code)=>{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(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 script=path.join(root,rel);if(fs.existsSync(script)){const result=spawnSync(process.execPath,[script,'stop:plan-canvas-pending','scripts/hooks/plan-canvas-pending.js','minimal,standard,strict'],{input:raw,encoding:'utf8',env:process.env,cwd:process.cwd(),timeout:30000,maxBuffer:16*1024*1024});const failed=result.error||result.status===null||result.signal;const stdout=!failed&&typeof result.stdout==='string'?result.stdout:'';let stderr=typeof result.stderr==='string'?result.stderr:'';let code=Number.isInteger(result.status)?result.status:0;if(failed){const reason=result.error?result.error.message:(result.signal?'signal '+result.signal:'missing exit status');stderr+='[Stop] ERROR: hook runner failed: '+reason+String.fromCharCode(10);code=1;}finish(stdout,stderr,code);}else{finish(raw,'[Stop] WARNING: could not resolve ECC plugin root; skipping hook'+String.fromCharCode(10),0);}\""
}
],
"description": "Deliver undelivered Plan Canvas browser feedback before the agent stops",
"id": "stop:plan-canvas-pending"
]
},
{
"matcher": ".*",
@@ -216,9 +181,7 @@
"command": "node -e \"const fs=require('fs');const path=require('path');const {spawnSync}=require('child_process');const raw=fs.readFileSync(0,'utf8');const finish=(out,err,code)=>{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(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 script=path.join(root,rel);if(fs.existsSync(script)){const result=spawnSync(process.execPath,[script,'stop:format-typecheck','scripts/hooks/stop-format-typecheck.js','standard,strict'],{input:raw,encoding:'utf8',env:process.env,cwd:process.cwd(),timeout:300000,maxBuffer:16*1024*1024});const failed=result.error||result.status===null||result.signal;const stdout=!failed&&typeof result.stdout==='string'?result.stdout:'';let stderr=typeof result.stderr==='string'?result.stderr:'';let code=Number.isInteger(result.status)?result.status:0;if(failed){const reason=result.error?result.error.message:(result.signal?'signal '+result.signal:'missing exit status');stderr+='[Stop] ERROR: hook runner failed: '+reason+String.fromCharCode(10);code=1;}finish(stdout,stderr,code);}else{finish(raw,'[Stop] WARNING: could not resolve ECC plugin root; skipping hook'+String.fromCharCode(10),0);}\"",
"timeout": 300
}
],
"description": "Batch format (Biome/Prettier) and typecheck (tsc) all JS/TS files edited this response — runs once at Stop instead of after every Edit",
"id": "stop:format-typecheck"
]
},
{
"matcher": ".*",
@@ -227,9 +190,7 @@
"type": "command",
"command": "node -e \"const fs=require('fs');const path=require('path');const {spawnSync}=require('child_process');const raw=fs.readFileSync(0,'utf8');const finish=(out,err,code)=>{let pending=1;const done=()=>{pending-=1;if(pending===0)process.exit(code);};if(out){pending+=1;process.stdout.write(out,done);}if(err){pending+=1;process.stderr.write(err,done);}process.nextTick(done);};const rel=path.join('scripts','hooks','run-with-flags.js');const root=(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 script=path.join(root,rel);if(fs.existsSync(script)){const result=spawnSync(process.execPath,[script,'stop:check-console-log','scripts/hooks/check-console-log.js','standard,strict'],{input:raw,encoding:'utf8',env:process.env,cwd:process.cwd(),timeout:30000,maxBuffer:16*1024*1024});const failed=result.error||result.status===null||result.signal;const stdout=!failed&&typeof result.stdout==='string'?result.stdout:'';let stderr=typeof result.stderr==='string'?result.stderr:'';let code=Number.isInteger(result.status)?result.status:0;if(failed){const reason=result.error?result.error.message:(result.signal?'signal '+result.signal:'missing exit status');stderr+='[Stop] ERROR: hook runner failed: '+reason+String.fromCharCode(10);code=1;}finish(stdout,stderr,code);}else{finish(raw,'[Stop] WARNING: could not resolve ECC plugin root; skipping hook'+String.fromCharCode(10),0);}\""
}
],
"description": "Check for console.log in modified files after each response",
"id": "stop:check-console-log"
]
},
{
"matcher": ".*",
@@ -240,9 +201,7 @@
"async": true,
"timeout": 10
}
],
"description": "Persist session state after each response (Stop carries transcript_path)",
"id": "stop:session-end"
]
},
{
"matcher": ".*",
@@ -253,9 +212,7 @@
"async": true,
"timeout": 10
}
],
"description": "Evaluate session for extractable patterns",
"id": "stop:evaluate-session"
]
},
{
"matcher": ".*",
@@ -266,9 +223,7 @@
"async": true,
"timeout": 10
}
],
"description": "Track token and cost metrics per session",
"id": "stop:cost-tracker"
]
},
{
"matcher": ".*",
@@ -279,9 +234,7 @@
"async": true,
"timeout": 10
}
],
"description": "Send desktop notification (macOS/WSL) with task summary when Claude responds",
"id": "stop:desktop-notify"
]
}
],
"SessionEnd": [
@@ -294,9 +247,7 @@
"async": true,
"timeout": 10
}
],
"description": "Session end lifecycle marker (non-blocking)",
"id": "session:end:marker"
]
}
]
}
+139
View File
@@ -0,0 +1,139 @@
{
"$schema": "../schemas/hooks-metadata.schema.json",
"entries": {
"PreToolUse": [
{
"id": "pre:bash:dispatcher",
"description": "Consolidated Bash preflight dispatcher for quality, tmux, push, and GateGuard checks",
"fingerprint": "0d30f37d2148"
},
{
"id": "pre:powershell:gateguard-fact-force",
"description": "PowerShell fact-forcing gate: inspect destructive commands without running unrelated Bash-only preflight hooks",
"fingerprint": "63877b632223"
},
{
"id": "pre:write:doc-file-warning",
"description": "Doc file warning: warn about non-standard documentation files (exit code 0; warns only)",
"fingerprint": "595406f864e2"
},
{
"id": "pre:edit-write:suggest-compact",
"description": "Suggest manual compaction at logical intervals",
"fingerprint": "d99998a8f039"
},
{
"id": "pre:observe:continuous-learning",
"description": "Capture tool use observations for continuous learning",
"fingerprint": "17f73ac2a883"
},
{
"id": "pre:governance-capture",
"description": "Capture governance events (secrets, policy violations, approval requests). Enable with ECC_GOVERNANCE_CAPTURE=1",
"fingerprint": "ffa978692652"
},
{
"id": "pre:config-protection",
"description": "Block modifications to linter/formatter config files. Steers agent to fix code instead of weakening configs.",
"fingerprint": "2b2be80bbeb3"
},
{
"id": "pre:mcp-health-check",
"description": "Check MCP server health before MCP tool execution and block unhealthy MCP calls",
"fingerprint": "922686364d01"
},
{
"id": "pre:edit-write:gateguard-fact-force",
"description": "Fact-forcing gate: block first Edit/Write/MultiEdit per file and demand investigation (importers, data schemas, user instruction) before allowing",
"fingerprint": "32c4a312b4c2"
}
],
"PreCompact": [
{
"id": "pre:compact",
"description": "Save state before context compaction",
"fingerprint": "5a4ef4aaa985"
}
],
"SessionStart": [
{
"id": "session:start",
"description": "Load previous context and detect package manager on new session",
"fingerprint": "3bbe414382a5"
},
{
"id": "session-start:plan-canvas-sessions",
"description": "Surface open Plan Canvas review sessions so a fresh session can resume the loop",
"fingerprint": "ad75ab423357"
}
],
"PostToolUse": [
{
"id": "post:dispatcher:sync",
"description": "Run synchronous PostToolUse hooks in one process while preserving per-hook controls",
"fingerprint": "cc868baab727"
},
{
"id": "post:dispatcher:async",
"description": "Run background PostToolUse hooks in one process while preserving per-hook controls",
"fingerprint": "5e256d15db44"
}
],
"PostToolUseFailure": [
{
"id": "post:mcp-health-check",
"description": "Track failed MCP tool calls, mark unhealthy servers, and attempt reconnect",
"fingerprint": "9e25549c1229"
},
{
"id": "post:skill:track",
"description": "Record hard Skill tool failures for skill-health telemetry",
"fingerprint": "a4dbe0729f30"
}
],
"Stop": [
{
"id": "stop:plan-canvas-pending",
"description": "Deliver undelivered Plan Canvas browser feedback before the agent stops",
"fingerprint": "e1a0fd79c26f"
},
{
"id": "stop:format-typecheck",
"description": "Batch format (Biome/Prettier) and typecheck (tsc) all JS/TS files edited this response — runs once at Stop instead of after every Edit",
"fingerprint": "9836d01e962e"
},
{
"id": "stop:check-console-log",
"description": "Check for console.log in modified files after each response",
"fingerprint": "235c7f182b76"
},
{
"id": "stop:session-end",
"description": "Persist session state after each response (Stop carries transcript_path)",
"fingerprint": "981212c32849"
},
{
"id": "stop:evaluate-session",
"description": "Evaluate session for extractable patterns",
"fingerprint": "d874ecf69ef7"
},
{
"id": "stop:cost-tracker",
"description": "Track token and cost metrics per session",
"fingerprint": "57d255146fc0"
},
{
"id": "stop:desktop-notify",
"description": "Send desktop notification (macOS/WSL) with task summary when Claude responds",
"fingerprint": "668cdbae027d"
}
],
"SessionEnd": [
{
"id": "session:end:marker",
"description": "Session end lifecycle marker (non-blocking)",
"fingerprint": "23a3832480e1"
}
]
}
}
+67
View File
@@ -0,0 +1,67 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ECC Hooks Metadata",
"description": "Stable ids and human-readable descriptions for the matcher entries in hooks/hooks.json. Kept in a sidecar because Claude Code reports any key outside its own hooks schema as an unknown key when the plugin loads.",
"type": "object",
"required": [
"entries"
],
"properties": {
"$schema": {
"type": "string"
},
"entries": {
"type": "object",
"description": "Event name to an array aligned by index with the same event's entries in hooks.json.",
"propertyNames": {
"enum": [
"SessionStart",
"UserPromptSubmit",
"PreToolUse",
"PermissionRequest",
"PostToolUse",
"PostToolUseFailure",
"Notification",
"SubagentStart",
"Stop",
"SubagentStop",
"PreCompact",
"InstructionsLoaded",
"TeammateIdle",
"TaskCompleted",
"ConfigChange",
"WorktreeCreate",
"WorktreeRemove",
"SessionEnd"
]
},
"additionalProperties": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"fingerprint"
],
"properties": {
"id": {
"type": "string",
"pattern": "\\S",
"description": "Stable, globally unique identifier for the matcher entry at this index."
},
"description": {
"type": "string"
},
"fingerprint": {
"type": "string",
"pattern": "^[0-9a-f]{12}$",
"description": "First 12 hex characters of the SHA-256 of the matcher entry (matcher + hooks, keys sorted) at this index in hooks.json. Binds the sidecar entry to a specific matcher so a reorder is detected. Regenerate with `node scripts/ci/validate-hooks.js --update-fingerprints`."
}
},
"additionalProperties": false
}
}
}
},
"additionalProperties": false
}
+160 -9
View File
@@ -8,8 +8,49 @@ const path = require('path');
const vm = require('vm');
const Ajv = require('ajv');
/**
* Resolve a module by its repo-relative path.
*
* Test harnesses copy this validator to the repo root before running it, so a
* plain relative require would break. Walk up from __dirname until the module
* is found instead.
*
* @param {string} repoRelativePath - e.g. 'scripts/lib/hooks-config.js'
* @returns {string} absolute path to the module
*/
function resolveRepoModule(repoRelativePath) {
let dir = __dirname;
for (;;) {
const candidate = path.join(dir, repoRelativePath);
if (fs.existsSync(candidate)) {
return candidate;
}
const parent = path.dirname(dir);
if (parent === dir) {
throw new Error(`Cannot locate ${repoRelativePath} above ${__dirname}`);
}
dir = parent;
}
}
const {
METADATA_FILENAME,
applyHooksMetadata,
findMetadataMismatches,
metadataPathFor,
withRefreshedFingerprints,
} = require(resolveRepoModule('scripts/lib/hooks-config.js'));
const HOOKS_FILE = path.join(__dirname, '../../hooks/hooks.json');
const HOOKS_SCHEMA_PATH = path.join(__dirname, '../../schemas/hooks.schema.json');
const METADATA_SCHEMA_PATH = path.join(__dirname, '../../schemas/hooks-metadata.schema.json');
// `--update-fingerprints` rewrites the sidecar's fingerprints from the current
// hooks.json instead of validating. Run it after changing a hook command.
const UPDATE_FINGERPRINTS = process.argv.includes('--update-fingerprints');
// Keys Claude Code's own hooks schema rejects. Keeping them out of hooks.json is
// what stops "unknown keys ... ignored" warnings when the plugin loads.
const HARNESS_UNKNOWN_ROOT_KEYS = ['$schema'];
const HARNESS_UNKNOWN_MATCHER_KEYS = ['id', 'description'];
const VALID_EVENTS = [
'SessionStart',
'UserPromptSubmit',
@@ -124,6 +165,78 @@ function validateHookEntry(hook, label) {
return hasErrors;
}
/**
* Reject keys the Claude Code harness does not understand.
*
* Claude Code validates a plugin's hooks.json against its own schema and prints
* every unrecognised key at load time. Once a hooks.metadata.json sidecar is
* present it owns the stable ids and descriptions, so hooks.json must not
* carry them as well.
*
* @param {object} data - Parsed hooks.json.
* @returns {boolean} true if errors were found
*/
function validateHarnessCompatibility(data) {
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return false;
}
let hasErrors = false;
for (const key of HARNESS_UNKNOWN_ROOT_KEYS) {
if (key in data) {
console.error(
`ERROR: hooks.json must not define "${key}" - Claude Code reports it as an unknown key`
);
hasErrors = true;
}
}
const events = data.hooks && typeof data.hooks === 'object' && !Array.isArray(data.hooks)
? data.hooks
: {};
for (const [eventType, matchers] of Object.entries(events)) {
if (!Array.isArray(matchers)) continue;
matchers.forEach((matcher, index) => {
if (!matcher || typeof matcher !== 'object') return;
for (const key of HARNESS_UNKNOWN_MATCHER_KEYS) {
if (key in matcher) {
console.error(
`ERROR: hooks.json ${eventType}[${index}] must not define "${key}" - `
+ `move it to ${METADATA_FILENAME}`
);
hasErrors = true;
}
}
});
}
return hasErrors;
}
/**
* Validate a parsed document against a JSON schema file, if the schema exists.
*
* @param {object} document - Parsed JSON to validate.
* @param {string} schemaPath - Path to the schema; skipped when absent.
* @param {string} label - Name used in error output.
* @returns {boolean} true if errors were found
*/
function validateAgainstSchema(document, schemaPath, label) {
if (!fs.existsSync(schemaPath)) {
return false;
}
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);
if (validate(document)) {
return false;
}
for (const err of validate.errors) {
console.error(`ERROR: ${label} schema: ${err.instancePath || '/'} ${err.message}`);
}
return true;
}
function validateHooks() {
if (!fs.existsSync(HOOKS_FILE)) {
console.log('No hooks.json found, skipping validation');
@@ -138,18 +251,51 @@ function validateHooks() {
process.exit(1);
}
// Validate against JSON schema
if (fs.existsSync(HOOKS_SCHEMA_PATH)) {
const schema = JSON.parse(fs.readFileSync(HOOKS_SCHEMA_PATH, 'utf-8'));
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);
const valid = validate(data);
if (!valid) {
for (const err of validate.errors) {
console.error(`ERROR: hooks.json schema: ${err.instancePath || '/'} ${err.message}`);
// Without a sidecar, hooks.json keeps its legacy inline ids. With one, the
// sidecar is the sole owner of id/description and hooks.json must stay
// within Claude Code's schema.
let metadata = null;
const metadataPath = metadataPathFor(HOOKS_FILE);
if (fs.existsSync(metadataPath)) {
try {
metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
} catch (e) {
console.error(`ERROR: Invalid JSON in ${METADATA_FILENAME}: ${e.message}`);
process.exit(1);
}
if (validateHarnessCompatibility(data)) {
process.exit(1);
}
if (UPDATE_FINGERPRINTS) {
try {
metadata = withRefreshedFingerprints(data, metadata);
} catch (error) {
console.error(`ERROR: ${error.message}`);
process.exit(1);
}
}
if (validateAgainstSchema(metadata, METADATA_SCHEMA_PATH, METADATA_FILENAME)) {
process.exit(1);
}
const mismatches = findMetadataMismatches(data, metadata);
if (mismatches.length > 0) {
for (const mismatch of mismatches) {
console.error(`ERROR: ${mismatch}`);
}
process.exit(1);
}
// Validate the merged view so the id/description rules below still apply.
data = applyHooksMetadata(data, metadata);
}
// Validate against JSON schema
if (validateAgainstSchema(data, HOOKS_SCHEMA_PATH, 'hooks.json')) {
process.exit(1);
}
// Support both object format { hooks: {...} } and array format
@@ -254,6 +400,11 @@ function validateHooks() {
process.exit(1);
}
if (UPDATE_FINGERPRINTS && metadata) {
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
console.log(`Updated fingerprints in ${METADATA_FILENAME}`);
}
console.log(`Validated ${totalMatchers} hook matchers`);
}
+4 -1
View File
@@ -19,6 +19,7 @@ const {
isAllowedOrigin,
} = require('./lib/loopback-guard');
const { normalizeAgentTools } = require('./lib/agent-tools');
const { readHooksConfig } = require('./lib/hooks-config');
const DEFAULT_HOST = '127.0.0.1';
@@ -129,7 +130,9 @@ function loadHooks(_root) {
const hooksPath = path.join(root, 'hooks', 'hooks.json');
if (!fs.existsSync(hooksPath)) return [];
try {
const data = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
// Ids and descriptions live in hooks/hooks.metadata.json so that hooks.json
// stays within the key set Claude Code's hooks schema accepts.
const data = readHooksConfig(hooksPath);
const hooks = [];
for (const [eventName, entries] of Object.entries(data.hooks || {})) {
for (const entry of entries || []) {
+318
View File
@@ -0,0 +1,318 @@
'use strict';
/**
* Read hooks/hooks.json together with its sibling hooks/hooks.metadata.json.
*
* Claude Code validates a plugin's hooks.json against its own schema and warns
* about every key it does not recognise, so ECC's stable matcher ids and
* human-readable descriptions cannot live in that file. They are kept in a
* sidecar keyed by event name and aligned with hooks.json entry order, and
* merged back here so the rest of ECC keeps seeing one object with `id` and
* `description` on each matcher entry.
*
* Index alignment alone cannot tell a reordered hooks.json from a correct one,
* so every sidecar entry also carries a fingerprint of the matcher entry it
* describes. A mismatch means the two files drifted apart.
*/
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const HOOKS_FILENAME = 'hooks.json';
const METADATA_FILENAME = 'hooks.metadata.json';
const FINGERPRINT_LENGTH = 12;
const FINGERPRINT_PATTERN = /^[0-9a-f]{12}$/;
function readJsonObject(filePath, label) {
let raw;
try {
raw = fs.readFileSync(filePath, 'utf8');
} catch (error) {
throw new Error(`Unable to read ${label} at ${filePath}: ${error.message}`);
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(`Invalid JSON in ${label} at ${filePath}: ${error.message}`);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`Invalid ${label} at ${filePath}: expected a JSON object`);
}
return parsed;
}
function metadataPathFor(hooksPath) {
return path.join(path.dirname(hooksPath), METADATA_FILENAME);
}
/**
* JSON.stringify with object keys sorted, so a fingerprint does not change when
* someone reorders the keys inside a hook object.
*/
function stableStringify(value) {
if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(',')}]`;
}
if (value && typeof value === 'object') {
return `{${Object.keys(value).sort().map(
key => `${JSON.stringify(key)}:${stableStringify(value[key])}`
).join(',')}}`;
}
return JSON.stringify(value);
}
/**
* Fingerprint the parts of a hooks.json matcher entry that identify it: its
* matcher and its hook commands. Ids and descriptions are excluded so the
* fingerprint is the same whether or not metadata has been merged in.
*
* @param {object} entry - A matcher entry from hooks.json.
* @returns {string} short hex digest
*/
function fingerprintHookEntry(entry) {
const subject = {
matcher: entry && 'matcher' in entry ? entry.matcher : null,
hooks: entry && Array.isArray(entry.hooks) ? entry.hooks : [],
};
return crypto.createHash('sha256')
.update(stableStringify(subject))
.digest('hex')
.slice(0, FINGERPRINT_LENGTH);
}
function eventsOf(hooksConfig) {
return hooksConfig && typeof hooksConfig.hooks === 'object' && hooksConfig.hooks
&& !Array.isArray(hooksConfig.hooks)
? hooksConfig.hooks
: null;
}
function metadataEntriesOf(metadata) {
return metadata && typeof metadata.entries === 'object' && metadata.entries
&& !Array.isArray(metadata.entries)
? metadata.entries
: null;
}
/**
* Merge sidecar metadata into a parsed hooks.json object.
*
* Neither argument is mutated; the returned config shares untouched matcher
* entries with the input and copies the ones that receive metadata.
*
* @param {object} hooksConfig - Parsed hooks.json.
* @param {object|null} metadata - Parsed hooks.metadata.json, or null when absent.
* @returns {object} a new hooks configuration with id/description restored.
*/
function applyHooksMetadata(hooksConfig, metadata) {
const events = eventsOf(hooksConfig);
const entriesByEvent = metadataEntriesOf(metadata);
if (!events || !entriesByEvent) {
return hooksConfig;
}
const mergedEvents = {};
for (const [event, entries] of Object.entries(events)) {
const eventMetadata = entriesByEvent[event];
if (!Array.isArray(entries) || !Array.isArray(eventMetadata)) {
mergedEvents[event] = entries;
continue;
}
mergedEvents[event] = entries.map((entry, index) => {
const entryMetadata = eventMetadata[index];
if (!entry || typeof entry !== 'object') return entry;
if (!entryMetadata || typeof entryMetadata !== 'object') return entry;
const merged = { ...entry };
if (typeof entryMetadata.id === 'string' && !('id' in entry)) {
merged.id = entryMetadata.id;
}
if (typeof entryMetadata.description === 'string' && !('description' in entry)) {
merged.description = entryMetadata.description;
}
return merged;
});
}
return { ...hooksConfig, hooks: mergedEvents };
}
/**
* Report entries whose metadata is missing, misaligned, or bound to a
* different matcher entry than the one at the same index.
*
* @param {object} hooksConfig - Parsed hooks.json.
* @param {object|null} metadata - Parsed hooks.metadata.json.
* @returns {string[]} human-readable problems; empty when the sidecar lines up.
*/
function findMetadataMismatches(hooksConfig, metadata) {
const problems = [];
const idLocations = new Map();
const events = eventsOf(hooksConfig) || {};
const entriesByEvent = metadataEntriesOf(metadata) || {};
for (const [event, entries] of Object.entries(events)) {
if (!Array.isArray(entries)) continue;
const eventMetadata = entriesByEvent[event];
if (!Array.isArray(eventMetadata)) {
problems.push(`${METADATA_FILENAME} is missing entries for event "${event}"`);
continue;
}
if (eventMetadata.length !== entries.length) {
problems.push(
`${METADATA_FILENAME} lists ${eventMetadata.length} entr(ies) for event "${event}" `
+ `but ${HOOKS_FILENAME} has ${entries.length}`
);
continue;
}
eventMetadata.forEach((entry, index) => {
const label = `${METADATA_FILENAME} ${event}[${index}]`;
if (!entry || typeof entry !== 'object') {
problems.push(`${label} is not an object`);
return;
}
if (typeof entry.id !== 'string' || entry.id.trim() === '') {
problems.push(`${label} is missing a non-empty "id"`);
} else if (idLocations.has(entry.id)) {
problems.push(`${label} has duplicate id "${entry.id}" already used by ${idLocations.get(entry.id)}`);
} else {
idLocations.set(entry.id, label);
}
if ('description' in entry && typeof entry.description !== 'string') {
problems.push(`${label} has a non-string "description"`);
}
if (typeof entry.fingerprint !== 'string' || !FINGERPRINT_PATTERN.test(entry.fingerprint)) {
problems.push(`${label} is missing a valid "fingerprint"`);
return;
}
const expected = fingerprintHookEntry(entries[index]);
if (entry.fingerprint !== expected) {
problems.push(
`${label} (id "${entry.id}") fingerprint ${entry.fingerprint} does not match `
+ `${HOOKS_FILENAME} ${event}[${index}] (${expected}); the entries were reordered `
+ 'or the hook command changed - regenerate with '
+ 'node scripts/ci/validate-hooks.js --update-fingerprints'
);
}
});
}
for (const event of Object.keys(entriesByEvent)) {
if (!Array.isArray(events[event])) {
problems.push(`${METADATA_FILENAME} describes event "${event}" which ${HOOKS_FILENAME} does not define`);
}
}
return problems;
}
/**
* Return a copy of the sidecar with every fingerprint recomputed from the
* matcher entry at the same index. Used to refresh the sidecar after hook
* commands change.
*
* @param {object} hooksConfig - Parsed hooks.json.
* @param {object} metadata - Parsed hooks.metadata.json.
* @returns {object} a new metadata object
*/
function withRefreshedFingerprints(hooksConfig, metadata) {
const events = eventsOf(hooksConfig) || {};
const entriesByEvent = metadataEntriesOf(metadata) || {};
const refreshed = {};
// A known fingerprint at another position signals a reorder, not a command
// edit. Require the author to move its metadata before refreshing anything.
const positions = new Map();
for (const [event, entries] of Object.entries(events)) {
if (!Array.isArray(entries)) continue;
entries.forEach((entry, index) => {
const fingerprint = fingerprintHookEntry(entry);
const locations = positions.get(fingerprint) || [];
positions.set(fingerprint, [...locations, `${event}[${index}]`]);
});
}
for (const [event, entries] of Object.entries(entriesByEvent)) {
if (!Array.isArray(entries)) continue;
entries.forEach((entry, index) => {
const locations = positions.get(entry?.fingerprint);
const location = `${event}[${index}]`;
if (locations && !locations.includes(location)) {
throw new Error(`Metadata reorder detected at ${location}; move the matching sidecar entry before refreshing fingerprints`);
}
});
}
for (const [event, eventMetadata] of Object.entries(entriesByEvent)) {
const entries = Array.isArray(events[event]) ? events[event] : [];
refreshed[event] = Array.isArray(eventMetadata)
? eventMetadata.map((entry, index) => (
entry && typeof entry === 'object' && index < entries.length
? { ...entry, fingerprint: fingerprintHookEntry(entries[index]) }
: entry
))
: eventMetadata;
}
return { ...metadata, entries: refreshed };
}
function assertMetadataAligned(hooksConfig, metadata, hooksPath) {
const mismatches = findMetadataMismatches(hooksConfig, metadata);
if (mismatches.length > 0) {
throw new Error(
`${METADATA_FILENAME} does not line up with ${hooksPath}:\n ${mismatches.join('\n ')}`
);
}
}
/**
* Merge a sidecar into a hooks config, rejecting a sidecar that does not line
* up. Shared by the readers below so a truncated or reordered sidecar fails
* loudly instead of producing entries with the wrong or missing ids.
*
* @param {object} hooksConfig - Parsed hooks.json.
* @param {object} metadata - Parsed hooks.metadata.json.
* @param {string} hooksPath - Used in the error message.
* @returns {object} a new merged hooks configuration
*/
function mergeHooksMetadata(hooksConfig, metadata, hooksPath = HOOKS_FILENAME) {
assertMetadataAligned(hooksConfig, metadata, hooksPath);
return applyHooksMetadata(hooksConfig, metadata);
}
/**
* Read hooks.json and return it with sidecar metadata merged in.
*
* @param {string} hooksPath - Path to hooks/hooks.json.
* @param {string} [label] - Label used in error messages.
* @returns {object} the merged hooks configuration.
*/
function readHooksConfig(hooksPath, label = HOOKS_FILENAME) {
const hooksConfig = readJsonObject(hooksPath, label);
const metadataPath = metadataPathFor(hooksPath);
if (!fs.existsSync(metadataPath)) {
return hooksConfig;
}
return mergeHooksMetadata(hooksConfig, readJsonObject(metadataPath, METADATA_FILENAME), hooksPath);
}
module.exports = {
HOOKS_FILENAME,
METADATA_FILENAME,
applyHooksMetadata,
findMetadataMismatches,
fingerprintHookEntry,
mergeHooksMetadata,
metadataPathFor,
readHooksConfig,
readJsonObject,
withRefreshedFingerprints,
};
+21 -1
View File
@@ -36,6 +36,7 @@ const { adaptAntigravityAgent } = require('./install/antigravity-agent');
const { buildInstallIndex, rewriteRelativeLinks } = require('./install/link-rewrite');
const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry');
const { resolveInvocationEnvironment } = require('./invocation-environment');
const { mergeHooksMetadata, metadataPathFor } = require('./hooks-config');
const OPENCODE_BUILD_ARTIFACT = path.join('.opencode', 'dist');
const OPENCODE_BUILD_SCRIPT = path.join('scripts', 'build-opencode.js');
const OPENCODE_PLUGIN_NOT_BUILT_CODE = 'opencode-plugin-not-built';
@@ -535,6 +536,25 @@ function readJsonNoFollow(filePath) {
return JSON.parse(readFileNoFollow(filePath, 'utf8'));
}
/**
* Read hooks.json and merge in hooks/hooks.metadata.json without following
* symlinks. The sidecar holds the stable matcher ids that hooks.json cannot
* carry, because Claude Code reports unknown keys when the plugin loads. A
* sidecar that does not line up with hooks.json is rejected before repair can
* reconcile matchers under the wrong ids.
*
* @param {string} hooksPath - Path to the source hooks.json.
* @returns {object} the hooks configuration with ids and descriptions restored.
*/
function readHooksConfigNoFollow(hooksPath) {
const hooksConfig = readJsonNoFollow(hooksPath);
const metadataPath = metadataPathFor(hooksPath);
if (!fs.existsSync(metadataPath)) {
return hooksConfig;
}
return mergeHooksMetadata(hooksConfig, readJsonNoFollow(metadataPath), hooksPath);
}
function assertClaudeSettingsDestination(operation, trustedRoot, target = null) {
if (target && target !== 'claude' && target !== 'claude-project') {
throw new Error('Refusing to manage Claude hooks for a non-Claude target.');
@@ -720,7 +740,7 @@ function hydrateRecordedOperations(repoRoot, operations, trustedRoot) {
sourcePath,
previousManagedHooks: operation.managedHooks,
managedHooks: materializeManagedHooks(
readJsonNoFollow(sourcePath),
readHooksConfigNoFollow(sourcePath),
trustedRoot
),
};
+4 -1
View File
@@ -5,6 +5,7 @@ const {
CLAUDE_HOOKS_CONFIG_PATH,
getClaudeSettingsPath,
} = require('../install/claude-settings');
const { METADATA_FILENAME } = require('../hooks-config');
const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({
'.claude-plugin': 'claude',
@@ -176,7 +177,9 @@ function planClaudeHooksOperations(adapter, module, input) {
return [
...operations,
...fs.readdirSync(sourceHooksRoot, { withFileTypes: true })
.filter(entry => entry.name !== 'hooks.json')
// hooks.json is merged into settings.json above, and its metadata sidecar
// is consumed with it, so neither is scaffolded into the target hooks dir.
.filter(entry => entry.name !== 'hooks.json' && entry.name !== METADATA_FILENAME)
.sort((left, right) => left.name.localeCompare(right.name))
.map(entry => adapter.createScaffoldOperation(
module.id,
+4 -1
View File
@@ -7,6 +7,7 @@ const { execFileSync } = require('child_process');
const { resolveInstallPlan } = require('../install-manifests');
const { getInstallTargetAdapter } = require('../install-targets/registry');
const { resolveInvocationEnvironment } = require('../invocation-environment');
const { readHooksConfig } = require('../hooks-config');
const {
materializeManagedHooks,
} = require('./claude-settings');
@@ -136,7 +137,9 @@ function materializeClaudeSettingsOperation(sourceRoot, operation) {
return [];
}
const hooksConfig = readJsonObject(sourcePath, operation.sourceRelativePath);
// Stable ids and descriptions live in hooks/hooks.metadata.json; readHooksConfig
// merges them back so managed settings entries keep their ids.
const hooksConfig = readHooksConfig(sourcePath, operation.sourceRelativePath);
const managedHooks = materializeManagedHooks(
hooksConfig,
path.dirname(operation.destinationPath)
@@ -17,6 +17,7 @@ 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'));
const { readHooksConfig } = require(path.join(repoRoot, 'scripts', 'lib', 'hooks-config.js'));
function test(name, fn) {
try {
@@ -31,7 +32,7 @@ function test(name, fn) {
}
function loadHook(id) {
const hookGroups = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')).hooks;
const hookGroups = readHooksConfig(hooksJsonPath).hooks;
const hooks = Object.values(hookGroups).flat();
const hook = hooks.find(candidate => candidate.id === id);
assert.ok(hook, `Expected ${id} in hooks/hooks.json`);
+293
View File
@@ -0,0 +1,293 @@
/**
* Tests for the hooks.json / hooks.metadata.json split.
*
* Claude Code validates a plugin's hooks.json against its own schema and prints
* every key it does not recognise when the plugin loads. These tests keep the
* unknown keys out of hooks.json and keep the sidecar aligned with it.
*
* Run with: node tests/hooks/hooks-metadata.test.js
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const {
applyHooksMetadata,
findMetadataMismatches,
fingerprintHookEntry,
metadataPathFor,
readHooksConfig,
withRefreshedFingerprints,
} = require('../../scripts/lib/hooks-config');
const REPO_ROOT = path.resolve(__dirname, '../..');
const HOOKS_PATH = path.join(REPO_ROOT, 'hooks', 'hooks.json');
const METADATA_PATH = metadataPathFor(HOOKS_PATH);
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function eachMatcher(hooksConfig, visit) {
for (const [event, entries] of Object.entries(hooksConfig.hooks || {})) {
(entries || []).forEach((entry, index) => visit(entry, `${event}[${index}]`));
}
}
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
test('hooks.json does not declare $schema', () => {
const hooksConfig = readJson(HOOKS_PATH);
assert.ok(
!('$schema' in hooksConfig),
'hooks.json must not define "$schema" - Claude Code reports it as an unknown key'
);
});
test('hooks.json matcher entries carry no id or description', () => {
const hooksConfig = readJson(HOOKS_PATH);
eachMatcher(hooksConfig, (entry, label) => {
assert.ok(!('id' in entry), `${label} must not define "id" - it belongs in hooks.metadata.json`);
assert.ok(
!('description' in entry),
`${label} must not define "description" - it belongs in hooks.metadata.json`
);
});
});
test('metadata sidecar exists and lines up with hooks.json', () => {
assert.ok(fs.existsSync(METADATA_PATH), 'hooks/hooks.metadata.json is missing');
const mismatches = findMetadataMismatches(readJson(HOOKS_PATH), readJson(METADATA_PATH));
assert.deepStrictEqual(mismatches, [], `metadata is misaligned:\n${mismatches.join('\n')}`);
});
test('every matcher entry has a unique id after merging', () => {
const merged = readHooksConfig(HOOKS_PATH);
const seen = new Map();
let count = 0;
eachMatcher(merged, (entry, label) => {
count += 1;
assert.ok(
typeof entry.id === 'string' && entry.id.trim() !== '',
`${label} has no id after merging metadata`
);
assert.ok(!seen.has(entry.id), `duplicate id "${entry.id}" at ${label} and ${seen.get(entry.id)}`);
seen.set(entry.id, label);
});
assert.ok(count > 0, 'expected at least one matcher entry');
});
test('merging leaves hook commands untouched', () => {
const raw = readJson(HOOKS_PATH);
const merged = readHooksConfig(HOOKS_PATH);
const commandsOf = config => Object.entries(config.hooks || {}).flatMap(([event, entries]) => (
(entries || []).flatMap((entry, index) => (entry.hooks || []).map(
(hook, hookIndex) => `${event}[${index}].hooks[${hookIndex}]:${JSON.stringify(hook)}`
))
));
assert.deepStrictEqual(commandsOf(merged), commandsOf(raw));
});
test('applyHooksMetadata does not overwrite an id already present', () => {
const hooksConfig = { hooks: { PreToolUse: [{ id: 'existing', matcher: 'Bash', hooks: [] }] } };
const merged = applyHooksMetadata(hooksConfig, { entries: { PreToolUse: [{ id: 'from-sidecar' }] } });
assert.strictEqual(merged.hooks.PreToolUse[0].id, 'existing');
});
test('applyHooksMetadata returns a new config and leaves its inputs untouched', () => {
const entry = { matcher: 'Bash', hooks: [{ type: 'command', command: 'node a.js' }] };
const hooksConfig = { hooks: { PreToolUse: [entry] } };
const metadata = { entries: { PreToolUse: [{ id: 'a', description: 'A' }] } };
const merged = applyHooksMetadata(hooksConfig, metadata);
assert.notStrictEqual(merged, hooksConfig);
assert.notStrictEqual(merged.hooks.PreToolUse[0], entry);
assert.deepStrictEqual(merged.hooks.PreToolUse[0], { ...entry, id: 'a', description: 'A' });
assert.deepStrictEqual(hooksConfig, { hooks: { PreToolUse: [entry] } });
assert.ok(!('id' in entry) && !('description' in entry), 'input entry must not be mutated');
assert.strictEqual(merged.hooks.PreToolUse[0].hooks, entry.hooks, 'untouched nested data is shared');
});
const alpha = { matcher: 'Bash', hooks: [{ type: 'command', command: 'node alpha.js' }] };
const beta = { matcher: 'Bash', hooks: [{ type: 'command', command: 'node beta.js' }] };
const alphaMeta = { id: 'a', fingerprint: fingerprintHookEntry(alpha) };
const betaMeta = { id: 'b', fingerprint: fingerprintHookEntry(beta) };
test('findMetadataMismatches reports length and coverage problems', () => {
const hooksConfig = { hooks: { PreToolUse: [alpha, beta] } };
assert.strictEqual(findMetadataMismatches(hooksConfig, { entries: {} }).length, 1);
assert.strictEqual(
findMetadataMismatches(hooksConfig, { entries: { PreToolUse: [alphaMeta] } }).length,
1
);
assert.strictEqual(
findMetadataMismatches(hooksConfig, {
entries: { PreToolUse: [alphaMeta, { ...betaMeta, id: '' }] },
}).length,
1
);
assert.strictEqual(
findMetadataMismatches(hooksConfig, {
entries: { PreToolUse: [alphaMeta, { ...betaMeta, description: 1 }] },
}).length,
1
);
assert.strictEqual(
findMetadataMismatches(hooksConfig, {
entries: { PreToolUse: [alphaMeta, betaMeta], Stop: [] },
}).length,
1
);
assert.deepStrictEqual(
findMetadataMismatches(hooksConfig, { entries: { PreToolUse: [alphaMeta, betaMeta] } }),
[]
);
});
test('findMetadataMismatches detects reordered entries and missing fingerprints', () => {
const hooksConfig = { hooks: { PreToolUse: [alpha, beta] } };
const reordered = findMetadataMismatches(hooksConfig, { entries: { PreToolUse: [betaMeta, alphaMeta] } });
assert.strictEqual(reordered.length, 2, 'each swapped entry is reported');
assert.match(reordered[0], /PreToolUse\[0\] \(id "b"\) fingerprint .* does not match/);
const changed = findMetadataMismatches(
{ hooks: { PreToolUse: [alpha, { ...beta, matcher: 'Write' }] } },
{ entries: { PreToolUse: [alphaMeta, betaMeta] } }
);
assert.strictEqual(changed.length, 1, 'a changed matcher invalidates the fingerprint');
const missing = findMetadataMismatches(hooksConfig, {
entries: { PreToolUse: [{ id: 'a' }, { id: 'b', fingerprint: 'nope' }] },
});
assert.strictEqual(missing.length, 2);
assert.match(missing[0], /missing a valid "fingerprint"/);
});
test('fingerprintHookEntry ignores id, description, and key order', () => {
const base = fingerprintHookEntry(alpha);
assert.match(base, /^[0-9a-f]{12}$/);
assert.strictEqual(fingerprintHookEntry({ ...alpha, id: 'x', description: 'y' }), base);
assert.strictEqual(
fingerprintHookEntry({ hooks: [{ command: 'node alpha.js', type: 'command' }], matcher: 'Bash' }),
base
);
assert.notStrictEqual(fingerprintHookEntry(beta), base);
});
test('withRefreshedFingerprints rewrites fingerprints without touching ids', () => {
const hooksConfig = { hooks: { PreToolUse: [alpha, beta] } };
const stale = {
$schema: 's',
entries: { PreToolUse: [{ id: 'a', fingerprint: '000000000000' }, { id: 'b' }] },
};
const refreshed = withRefreshedFingerprints(hooksConfig, stale);
assert.deepStrictEqual(refreshed, { $schema: 's', entries: { PreToolUse: [alphaMeta, betaMeta] } });
assert.deepStrictEqual(findMetadataMismatches(hooksConfig, refreshed), []);
assert.strictEqual(stale.entries.PreToolUse[0].fingerprint, '000000000000', 'input is not mutated');
});
test('readHooksConfig rejects a sidecar that does not line up', () => {
const tempDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'ecc-hooks-'));
const tempHooks = path.join(tempDir, 'hooks.json');
fs.writeFileSync(tempHooks, JSON.stringify({ hooks: { PreToolUse: [alpha, beta] } }));
fs.writeFileSync(
metadataPathFor(tempHooks),
JSON.stringify({ entries: { PreToolUse: [betaMeta, alphaMeta] } })
);
try {
assert.throws(() => readHooksConfig(tempHooks), /does not line up with .*hooks\.json[\s\S]*fingerprint/);
fs.writeFileSync(
metadataPathFor(tempHooks),
JSON.stringify({ entries: { PreToolUse: [alphaMeta, betaMeta] } })
);
const merged = readHooksConfig(tempHooks);
assert.deepStrictEqual(merged.hooks.PreToolUse.map(entry => entry.id), ['a', 'b']);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('readHooksConfig returns raw config when the sidecar is absent', () => {
const tempDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'ecc-hooks-'));
const tempHooks = path.join(tempDir, 'hooks.json');
fs.writeFileSync(tempHooks, JSON.stringify({ hooks: { Stop: [{ hooks: [] }] } }));
try {
const config = readHooksConfig(tempHooks);
assert.deepStrictEqual(config, { hooks: { Stop: [{ hooks: [] }] } });
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('failed refresh validation preserves the original sidecar bytes', () => {
const root = fs.mkdtempSync(path.join(require('os').tmpdir(), 'ecc-metadata-refresh-'));
try {
for (const relative of ['scripts/ci/validate-hooks.js', 'scripts/lib/hooks-config.js',
'schemas/hooks.schema.json', 'schemas/hooks-metadata.schema.json']) {
const destination = path.join(root, relative);
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.copyFileSync(path.join(REPO_ROOT, relative), destination);
}
fs.mkdirSync(path.join(root, 'hooks'));
fs.writeFileSync(path.join(root, 'hooks/hooks.json'), JSON.stringify({ hooks: { PreToolUse: [alpha] } }));
const sidecar = path.join(root, 'hooks/hooks.metadata.json');
const original = JSON.stringify({ entries: { PreToolUse: [{ ...alphaMeta, id: '', fingerprint: '000000000000' }] } });
fs.writeFileSync(sidecar, original);
const result = require('child_process').spawnSync(process.execPath,
[path.join(root, 'scripts/ci/validate-hooks.js'), '--update-fingerprints'], {
encoding: 'utf8', env: { ...process.env, NODE_PATH: path.join(REPO_ROOT, 'node_modules') },
});
assert.strictEqual(result.status, 1, result.stderr);
assert.match(result.stderr, /id|non-empty/);
assert.strictEqual(fs.readFileSync(sidecar, 'utf8'), original);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test('refresh refuses reordered hooks instead of rebinding stable ids', () => {
const config = { hooks: { PreToolUse: [beta, alpha] } };
const metadata = { entries: { PreToolUse: [alphaMeta, betaMeta] } };
assert.throws(() => withRefreshedFingerprints(config, metadata), /reorder/i);
assert.deepStrictEqual(metadata.entries.PreToolUse, [alphaMeta, betaMeta]);
});
test('alignment rejects duplicate ids across events', () => {
const config = { hooks: { PreToolUse: [alpha], PostToolUse: [beta] } };
const metadata = { entries: {
PreToolUse: [alphaMeta], PostToolUse: [{ ...betaMeta, id: alphaMeta.id }],
} };
assert.ok(findMetadataMismatches(config, metadata).some(problem =>
/duplicate/.test(problem) && /PreToolUse/.test(problem) && /PostToolUse/.test(problem)));
});
let failures = 0;
for (const { name, fn } of tests) {
try {
fn();
console.log(` PASS ${name}`);
} catch (error) {
failures += 1;
console.error(` FAIL ${name}`);
console.error(` ${error.message}`);
}
}
console.log(`\nResults: Passed: ${tests.length - failures}, Failed: ${failures}`);
process.exit(failures === 0 ? 0 : 1);
+4 -3
View File
@@ -9,6 +9,7 @@ const path = require('path');
const fs = require('fs');
const os = require('os');
const { execFileSync, spawn, spawnSync } = require('child_process');
const { readHooksConfig } = require('../../scripts/lib/hooks-config');
const SKIP_BASH = process.platform === 'win32';
@@ -2573,7 +2574,7 @@ async function runTests() {
if (
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 hooks = readHooksConfig(hooksPath);
const preBash = hooks.hooks.PreToolUse.filter(entry => entry.matcher === 'Bash');
const postEntries = hooks.hooks.PostToolUse;
@@ -2602,7 +2603,7 @@ async function runTests() {
if (
test('hooks.json gives PowerShell dedicated GateGuard and governance routes', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
const hooks = readHooksConfig(hooksPath);
const powerShellRoutes = hooks.hooks.PreToolUse.filter(entry => entry.matcher === 'PowerShell');
const governanceRoute = hooks.hooks.PreToolUse.find(entry => entry.id === 'pre:governance-capture');
@@ -2641,7 +2642,7 @@ async function runTests() {
if (
test('configured PowerShell routes enforce denial and emit redacted governance evidence', () => {
const root = path.join(__dirname, '..', '..');
const hooks = JSON.parse(fs.readFileSync(path.join(root, 'hooks', 'hooks.json'), 'utf8'));
const hooks = readHooksConfig(path.join(root, 'hooks', 'hooks.json'));
const gateRoute = hooks.hooks.PreToolUse.find(entry => entry.id === 'pre:powershell:gateguard-fact-force');
const governanceRoute = hooks.hooks.PreToolUse.find(entry => entry.id === 'pre:governance-capture');
const stateDir = createTestDir();
+6 -5
View File
@@ -13,6 +13,7 @@ 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');
const { readHooksConfig } = require(path.join(repoRoot, 'scripts', 'lib', 'hooks-config.js'));
function test(name, fn) {
try {
@@ -78,7 +79,7 @@ function runTests() {
if (
test('hooks.json exposes one sync and one async PostToolUse entry', () => {
const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse;
const entries = readHooksConfig(hooksPath).hooks.PostToolUse;
assert.strictEqual(entries.length, 2, 'PostToolUse should launch at most two commands');
assert.deepStrictEqual(
entries.map(entry => entry.id),
@@ -162,7 +163,7 @@ function runTests() {
if (
test('actual hooks.json commands preserve Edit dry-run output and IDs', () => {
const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse;
const entries = readHooksConfig(hooksPath).hooks.PostToolUse;
const raw = JSON.stringify({
hook_event_name: 'PostToolUse',
tool_name: 'Edit',
@@ -194,7 +195,7 @@ function runTests() {
if (
test('actual hooks.json commands never echo truncated oversized input', () => {
const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse;
const entries = readHooksConfig(hooksPath).hooks.PostToolUse;
const values = ['x'.repeat(1024 * 1024 + 1024), 'é'.repeat(600000), '\u{1F600}'.repeat(300000)];
for (const value of values) {
@@ -270,7 +271,7 @@ function runTests() {
if (
test('public dispatcher IDs disable their complete phase', () => {
const entries = JSON.parse(fs.readFileSync(hooksPath, 'utf8')).hooks.PostToolUse;
const entries = readHooksConfig(hooksPath).hooks.PostToolUse;
const raw = JSON.stringify({
hook_event_name: 'PostToolUse',
tool_name: 'Edit',
@@ -480,7 +481,7 @@ function runTests() {
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;
const entries = readHooksConfig(hooksPath).hooks.PostToolUse;
assert.ok(
entries.every(entry => entry.hooks[0].command.includes('require(s).cli()')),
'hooks.json must invoke the explicit cli() entrypoint'
+2 -3
View File
@@ -16,6 +16,7 @@ const os = require('os');
const path = require('path');
const { buildRecord, deriveOutcome, extractSkillId, run } = require('../../scripts/hooks/skill-run-tracker');
const { readHooksConfig } = require('../../scripts/lib/hooks-config');
const {
MAX_RUN_RECORDS,
RUNS_FILE_MODE,
@@ -238,9 +239,7 @@ test('an end-to-end Skill hook run lands exactly one non-sensitive record', () =
// needs its own hooks.json entry. Without it, hard Skill failures are silently
// dropped and the dashboard's success rate is inflated.
test('the tracker is registered for PostToolUseFailure so hard failures are recorded', () => {
const hooksConfig = JSON.parse(
fs.readFileSync(path.join(__dirname, '..', '..', 'hooks', 'hooks.json'), 'utf8')
);
const hooksConfig = readHooksConfig(path.join(__dirname, '..', '..', 'hooks', 'hooks.json'));
const entries = (hooksConfig.hooks.PostToolUseFailure || [])
.filter(entry => entry.id === 'post:skill:track');
+2 -3
View File
@@ -24,9 +24,8 @@ const { spawnSync } = require('child_process');
const repoRoot = path.join(__dirname, '..', '..');
const runner = path.join(repoRoot, 'scripts', 'hooks', 'run-with-flags.js');
const hooksConfig = JSON.parse(
fs.readFileSync(path.join(repoRoot, 'hooks', 'hooks.json'), 'utf8')
);
const { readHooksConfig } = require(path.join(repoRoot, 'scripts', 'lib', 'hooks-config.js'));
const hooksConfig = readHooksConfig(path.join(repoRoot, 'hooks', 'hooks.json'));
const MAX_STDIN = 1024 * 1024;
const SUBPROCESS_TIMEOUT_MS = process.platform === 'darwin' && process.env.CI === 'true'
+2 -1
View File
@@ -12,6 +12,7 @@ const path = require('path');
const fs = require('fs');
const os = require('os');
const { spawn } = require('child_process');
const { readHooksConfig } = require('../../scripts/lib/hooks-config');
const REPO_ROOT = path.join(__dirname, '..', '..');
// Test helper
@@ -282,7 +283,7 @@ async function runTests() {
const scriptsDir = path.join(__dirname, '..', '..', 'scripts', 'hooks');
const hooksJsonPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
const hooks = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8'));
const hooks = readHooksConfig(hooksJsonPath);
// ==========================================
// Input Format Tests
+2 -1
View File
@@ -27,6 +27,7 @@ const {
assertClaudeSettingsPath,
materializeManagedHooks,
} = require('../../scripts/lib/install/claude-settings');
const { readHooksConfig } = require('../../scripts/lib/hooks-config');
const REPO_ROOT = path.join(__dirname, '..', '..');
const CURRENT_PACKAGE_VERSION = JSON.parse(
@@ -158,7 +159,7 @@ function managedHookEntry(id, command) {
function currentManagedHooks(targetRoot) {
return materializeManagedHooks(
JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'hooks', 'hooks.json'), 'utf8')),
readHooksConfig(path.join(REPO_ROOT, 'hooks', 'hooks.json')),
targetRoot
);
}
+2 -1
View File
@@ -17,6 +17,7 @@
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { readHooksConfig } = require('../scripts/lib/hooks-config');
const repoRoot = path.resolve(__dirname, '..');
const packageJsonPath = path.join(repoRoot, 'package.json');
@@ -394,7 +395,7 @@ test('codex lifecycle hook bundle contains only Codex 0.146-supported schema', (
}
}
const claudeConfig = loadJsonObject(path.join(repoRoot, 'hooks', 'hooks.json'), 'hooks/hooks.json');
const claudeConfig = readHooksConfig(path.join(repoRoot, 'hooks', 'hooks.json'), 'hooks/hooks.json');
const sourceSessionStart = claudeConfig.hooks.SessionStart.find(group => group.id === 'session:start');
const expectedSessionStart = {
...sourceSessionStart,