mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-12 20:57:59 +02:00
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:
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || []) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user