Files
ECC/scripts/hooks/posttooluse-dispatcher.js
01e15490f0 fix(skill-evolution): wire Skill PostToolUse tracker so skill-health shows real runs (#2490)
* fix(skill-evolution): wire Skill PostToolUse tracker so skill-health shows real runs (#2463)

recordSkillExecution() had no production callers, so
~/.claude/state/skill-runs.jsonl was never written and
`scripts/skills-health.js --dashboard` always reported 0 runs.

Adds scripts/hooks/skill-run-tracker.js and registers it as an async
PostToolUse hook (matcher: Skill) in posttooluse-dispatcher.js, which is now
the single PostToolUse entrypoint on main.

Addresses the privacy and bounds review on #2555's sibling PR:

- No prompt text is persisted. task_description is synthesized as
  "Skill invocation: <skill_id>"; tool_input.task_description/description/
  prompt are never read.
- Every persisted string is bounded and charset-restricted. A skill id is an
  identifier, so free text, newlines, or an over-long value are dropped rather
  than truncated and written through.
- The JSONL sink is created 0600 and re-tightened on each append, repairing
  files written before this bound existed.
- The sink is capped at MAX_RUN_RECORDS (5000), trimmed oldest-first, so the
  append-only file can no longer grow without limit.

Tests cover the privacy guarantee (no prompt text reaches a record), the
identifier bounds, the file mode on POSIX, and the retention cap.

* fix(skill-evolution): re-register the tracker for PostToolUseFailure

The rebase onto current main dropped the hooks.json entry, which silently
resurrected the P1 from the earlier review round: deriveOutcome() still
branches on hook_event_name === 'PostToolUseFailure', but the PostToolUse
dispatcher does not fan that event out, so the branch was unreachable in
production. Hard Skill failures were dropped from telemetry entirely, which
inflates the dashboard success rate — the opposite of what #2463 asks for.

Restores the dedicated PostToolUseFailure entry (matcher Skill, id
post:skill:track, same run-with-flags wrapper and standard,strict gating as
the dispatcher registration). Verified end-to-end: a PostToolUseFailure
payload piped through run-with-flags now records outcome "failure".

Adds a regression test asserting the registration so a future rebase cannot
quietly drop it again.

---------

Co-authored-by: haelyra <49814733+haelyra@users.noreply.github.com>
2026-08-12 18:12:49 -04:00

290 lines
10 KiB
JavaScript

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