mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-14 20:25:26 +02:00
Under OpenCode the skill-health dashboard had no writer and no honest fallback, so it reported every discovered skill as healthy (#2463). Root cause: `post:skill:track` is only wired for Claude Code, where `posttooluse-dispatcher.js` fans the event out to `skill-run-tracker.js`. The OpenCode plugin re-implements the harness events instead of reusing that dispatcher, so nothing ever wrote `skill-runs.jsonl` — and the tracker's matcher only accepted the exact Claude Code tool name `Skill`, while OpenCode calls the tool `skill`, so even wiring it would have dropped every event. Commands that locate ECC through the inlined resolver in `scripts/lib/resolve-ecc-root.js` also fell back to `~/.claude`, which does not contain ECC, so they could not find their scripts. Changes: - `.opencode/plugins/ecc-hooks.ts`: dispatch `post:skill:track` from `tool.execute.after`. The tracker module is imported lazily and behind try/catch, matching the existing `changed-files-store` pattern: this file is OpenCode's startup entry point, so a static import failure would crash the session before any hook runs (#2530). The module is reused rather than reimplemented, keeping its identifier validation, length bounds and "never persist prompt text" guarantees. - `scripts/hooks/skill-run-tracker.js`: match the tool name case-insensitively so `Skill` and `skill` are both accepted. - `scripts/lib/skill-evolution/health.js`: split the summary into measured/healthy/declining/unmeasured instead of deriving `healthy = total - declining`, which turned "no telemetry" into "everything is fine" for every skill. - `scripts/lib/skill-evolution/dashboard.js`: show the breakdown and warn explicitly when skills have no recorded runs, since absence of telemetry is not a health signal. Under OpenCode the resolver also needs `CLAUDE_PLUGIN_ROOT`, which only the plugin can supply: `shell.env` exports it when a root is found, without replacing a root the user set or one an earlier plugin already put in `output.env`. Root discovery is per capability and probes required runtime files — never optional content. Probing for `skills/continuous-learning-v2` (the `workflow-quality` module) rejected supported modular installs that ship the plugin and tracker without that optional module, disabling telemetry and the root export for exactly those users. Also fixes an incidental bug: the compiled plugin bundle is ESM, where `__dirname` is undefined, so the ECC version lookup silently fell back to its default. The module directory is now derived from `import.meta.url`. Tests: `tests/opencode-plugin-hooks.test.js` covers the telemetry dispatch (skill tool recorded, other tools ignored, minimal profile respected), a modular install without the optional skills module, the root export and both override paths; `tests/hooks/skill-run-tracker.test.js` covers the case-insensitive matcher; `tests/lib/skill-evolution.test.js` and `tests/lib/skill-dashboard.test.js` cover the unmeasured bucket, the warning and the aged-out-coverage wording. Refs #2463, #2530
183 lines
5.9 KiB
JavaScript
183 lines
5.9 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. Under Claude Code it runs via the
|
|
* PostToolUse dispatcher, which owns stdin, pass-through, and exit codes; under
|
|
* OpenCode the ecc-hooks plugin calls run() directly with an equivalent payload.
|
|
*
|
|
* 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';
|
|
}
|
|
|
|
const SKILL_TOOL_NAME = 'skill';
|
|
|
|
/**
|
|
* Report whether a hook payload describes a skill tool invocation.
|
|
*
|
|
* Claude Code names the tool `Skill`; OpenCode and other harnesses name it
|
|
* `skill`. Comparing the raw string meant a case difference silently disabled
|
|
* the whole write side again — the hook ran, dropped every event, and the
|
|
* dashboard kept reporting unmeasured skills with no hint why.
|
|
*
|
|
* @param {{tool_name?: unknown} | null} payload
|
|
* @returns {boolean}
|
|
*/
|
|
function isSkillToolCall(payload) {
|
|
if (!payload || typeof payload !== 'object') {
|
|
return false;
|
|
}
|
|
|
|
const toolName = payload.tool_name;
|
|
return typeof toolName === 'string' && toolName.trim().toLowerCase() === SKILL_TOOL_NAME;
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Record a skill invocation from a hook payload.
|
|
*
|
|
* Accepts the raw JSON string Claude Code's dispatcher reads from stdin, or an
|
|
* already-parsed payload for in-process callers such as the OpenCode plugin.
|
|
* Non-skill tools and unparsable input are ignored.
|
|
*
|
|
* @param {string | object | null | undefined} rawInput
|
|
* @returns {void}
|
|
*/
|
|
function run(rawInput) {
|
|
try {
|
|
const payload = typeof rawInput === 'string'
|
|
? (rawInput.trim() ? JSON.parse(rawInput) : {})
|
|
: rawInput;
|
|
if (isSkillToolCall(payload)) {
|
|
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, isSkillToolCall, run };
|