Files
ECC/scripts/hooks/skill-run-tracker.js
T
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

150 lines
4.8 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* PostToolUse hook: record Skill tool invocations for skill-health telemetry.
*
* Wires the write side of the already-shipped JSONL tracker
* (scripts/lib/skill-evolution/tracker.js). Before this hook,
* recordSkillExecution() had zero production callers, so
* ~/.claude/state/skill-runs.jsonl was never written and
* `scripts/skills-health.js --dashboard` always reported 0 runs (#2463).
*
* Privacy: the dashboard aggregates skill/version/outcome only, so this hook
* never persists prompt text. `task_description` is synthesized from the skill
* id, and every persisted string is charset-restricted and length-bounded — a
* skill id is an identifier, not free text, so anything that does not look like
* one is dropped rather than written through.
*
* Best-effort: never blocks tool execution. Runs under the PostToolUse
* dispatcher, which owns stdin, pass-through, and exit codes.
*
* Cross-platform (Windows, macOS, Linux); CommonJS.
*/
const { recordSkillExecution } = require('../lib/skill-evolution/tracker');
// Bounds for persisted identifiers. Long enough for any real skill id or
// semver-ish version, short enough that a stray blob cannot ride in.
const MAX_SKILL_ID = 128;
const MAX_SKILL_VERSION = 64;
// Identifiers only: letters, digits, and the separators skill ids actually use.
// Anything else (whitespace, punctuation, newlines) means it is not an id.
const SKILL_ID_PATTERN = /^[A-Za-z0-9._:@/-]+$/;
const SKILL_VERSION_PATTERN = /^[A-Za-z0-9._+-]+$/;
// Return `value` only if it is a bounded, identifier-shaped string.
function boundedIdentifier(value, maxLength, pattern) {
if (typeof value !== 'string') {
return null;
}
const trimmed = value.trim();
if (trimmed.length === 0 || trimmed.length > maxLength) {
return null;
}
return pattern.test(trimmed) ? trimmed : null;
}
function firstIdentifier(maxLength, pattern, ...values) {
for (const value of values) {
const identifier = boundedIdentifier(value, maxLength, pattern);
if (identifier) {
return identifier;
}
}
return null;
}
// Extract the skill identifier from the Skill tool input across the field
// names Claude Code has used for it. The Skill tool is genuinely un-wired in
// this repo, so no single canonical field is guaranteed — probe the plausible
// ones and bail (record nothing) if none is present.
function extractSkillId(toolInput) {
if (typeof toolInput === 'string') {
return boundedIdentifier(toolInput, MAX_SKILL_ID, SKILL_ID_PATTERN);
}
if (!toolInput || typeof toolInput !== 'object') {
return null;
}
return firstIdentifier(
MAX_SKILL_ID,
SKILL_ID_PATTERN,
toolInput.skill_id,
toolInput.skillId,
toolInput.skill,
toolInput.name,
toolInput.command
);
}
// Best-effort outcome: a failed tool call is recorded as "failure", everything
// else as "success". Both PostToolUseFailure routing and an error-bearing
// tool response are treated as failure.
function deriveOutcome(payload) {
if (payload && payload.hook_event_name === 'PostToolUseFailure') {
return 'failure';
}
const response = (payload && (payload.tool_response ?? payload.tool_output)) || null;
if (response && typeof response === 'object') {
if (response.is_error === true || response.isError === true) {
return 'failure';
}
if (typeof response.status === 'string' && /error|fail/i.test(response.status)) {
return 'failure';
}
if (typeof response.error === 'string' && response.error.trim().length > 0) {
return 'failure';
}
}
return 'success';
}
function buildRecord(payload) {
const skillId = extractSkillId(payload.tool_input);
if (!skillId) {
return null; // cannot satisfy the tracker's required skill_id — skip
}
const input = payload.tool_input && typeof payload.tool_input === 'object'
? payload.tool_input
: {};
const skillVersion = firstIdentifier(
MAX_SKILL_VERSION,
SKILL_VERSION_PATTERN,
input.skill_version,
input.skillVersion,
input.version
) || 'unknown';
return {
skill_id: skillId,
skill_version: skillVersion,
// Synthesized, not user content. The tracker requires a non-empty
// task_description; the dashboard never displays it as prose.
task_description: `Skill invocation: ${skillId}`,
outcome: deriveOutcome(payload),
};
}
function run(rawInput) {
try {
const payload = typeof rawInput === 'string'
? (rawInput.trim() ? JSON.parse(rawInput) : {})
: rawInput;
if (payload && typeof payload === 'object' && payload.tool_name === 'Skill') {
const record = buildRecord(payload);
if (record) {
recordSkillExecution(record);
}
}
} catch {
// Telemetry is best-effort; never block tool execution on a failure here.
}
}
module.exports = { buildRecord, deriveOutcome, extractSkillId, run };