diff --git a/hooks/README.md b/hooks/README.md index 144bc89ad..620ef981f 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -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. diff --git a/hooks/hooks.json b/hooks/hooks.json index 62053904e..641873396 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -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{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{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{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 { + 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`); } diff --git a/scripts/dashboard-web.js b/scripts/dashboard-web.js index 044a20fd7..5524853bd 100644 --- a/scripts/dashboard-web.js +++ b/scripts/dashboard-web.js @@ -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 || []) { diff --git a/scripts/lib/hooks-config.js b/scripts/lib/hooks-config.js new file mode 100644 index 000000000..11515c4bc --- /dev/null +++ b/scripts/lib/hooks-config.js @@ -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, +}; diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index 99ec19614..ec9cb1d80 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -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 ), }; diff --git a/scripts/lib/install-targets/helpers.js b/scripts/lib/install-targets/helpers.js index dbb5b44e5..f69d75e86 100644 --- a/scripts/lib/install-targets/helpers.js +++ b/scripts/lib/install-targets/helpers.js @@ -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, diff --git a/scripts/lib/install/plan.js b/scripts/lib/install/plan.js index 08173a672..1400557c7 100644 --- a/scripts/lib/install/plan.js +++ b/scripts/lib/install/plan.js @@ -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) diff --git a/tests/hooks/continuous-learning-observe-runner.test.js b/tests/hooks/continuous-learning-observe-runner.test.js index 37ca3ce31..9445a3c1d 100644 --- a/tests/hooks/continuous-learning-observe-runner.test.js +++ b/tests/hooks/continuous-learning-observe-runner.test.js @@ -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`); diff --git a/tests/hooks/hooks-metadata.test.js b/tests/hooks/hooks-metadata.test.js new file mode 100644 index 000000000..5294d91fe --- /dev/null +++ b/tests/hooks/hooks-metadata.test.js @@ -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); diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 93ad6133b..635566b27 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -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(); diff --git a/tests/hooks/posttooluse-dispatcher.test.js b/tests/hooks/posttooluse-dispatcher.test.js index c21f003f3..ae6dbaaa1 100644 --- a/tests/hooks/posttooluse-dispatcher.test.js +++ b/tests/hooks/posttooluse-dispatcher.test.js @@ -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' diff --git a/tests/hooks/skill-run-tracker.test.js b/tests/hooks/skill-run-tracker.test.js index bd97b50c8..d3e63e247 100644 --- a/tests/hooks/skill-run-tracker.test.js +++ b/tests/hooks/skill-run-tracker.test.js @@ -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'); diff --git a/tests/hooks/stop-hooks-stdout.test.js b/tests/hooks/stop-hooks-stdout.test.js index 3d0617c57..5d3efdf86 100644 --- a/tests/hooks/stop-hooks-stdout.test.js +++ b/tests/hooks/stop-hooks-stdout.test.js @@ -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' diff --git a/tests/integration/hooks.test.js b/tests/integration/hooks.test.js index 677e2b952..96ad9b4d1 100644 --- a/tests/integration/hooks.test.js +++ b/tests/integration/hooks.test.js @@ -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 diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index ab20e54ca..1eed5071b 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -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 ); } diff --git a/tests/plugin-manifest.test.js b/tests/plugin-manifest.test.js index 8f4ac1ba0..74bd25ec4 100644 --- a/tests/plugin-manifest.test.js +++ b/tests/plugin-manifest.test.js @@ -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,