#!/usr/bin/env node /** * Rule #2 — Coverage tag verified against artifacts (Stop hook). * * Reads transcript at Stop event. Parses `coverage: :` from last * assistant text. Then: * - channel=skill / id=X — require Skill tool_use with input.skill === X * - channel=node — accept any tool_use that produced work (>= 1 mutating tool) * - channel=direct — accept (Rule #8 handles direct-vs-classifier mismatch) * - channel=chain / hook / agent — accept (lighter discipline) * - missing coverage line — block * * Override: "без скилов" / "direct ok" suppress this rule. * * NB: only fires when the assistant ACTUALLY did some work (>=1 tool_use). * Pure conversational turns (no tool calls) pass without coverage requirement. * * Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md */ import { readStdin, parseEventJson, readTranscript, lastUserPromptText, lastAssistantText, parseCoverageLine, turnToolUses, findOverride, logOverride, exitDecision, } from './enforce-hook-helpers.mjs'; const RULE_KEY = 'coverage-skill-match'; const MUTATING_TOOLS = new Set([ 'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', ]); export function decide({ toolUses, assistantText, override, }) { // Pure conversational turn — skip. const hasMutating = toolUses.some((u) => MUTATING_TOOLS.has(u.name)); if (!hasMutating) return { block: false }; if (override) return { block: false }; const cov = parseCoverageLine(assistantText); if (!cov) { return { block: true, message: [ `[enforce-coverage-verify] Turn performed mutating tool calls but assistant response has no \`coverage:\` line.`, `Add as first line of next response:`, ` coverage: skill: (e.g., skill:superpowers:test-driven-development)`, ` coverage: direct: (e.g., direct:memory-sync, direct:git-recovery)`, ``, `Override: include "без скилов" or "direct ok" in your prompt.`, ].join('\n'), }; } if (cov.channel === 'skill') { const found = toolUses.some((u) => u.name === 'Skill' && u.input && (u.input.skill === cov.id || u.input.skill === cov.id.replace(/^superpowers:/, ''))); if (!found) { return { block: true, message: [ `[enforce-coverage-verify] coverage says skill:${cov.id} but the Skill tool was never invoked with that name in this turn.`, `Either invoke the skill via Skill tool, or switch coverage to direct: with justification.`, ].join('\n'), }; } return { block: false }; } // direct / node / chain / hook / agent — accepted at this layer. return { block: false }; } async function main() { try { const raw = await readStdin(); const event = parseEventJson(raw); const transcript = readTranscript(event.transcript_path); const userPrompt = lastUserPromptText(transcript); const override = findOverride(userPrompt, RULE_KEY); if (override) logOverride(RULE_KEY, override, event.session_id); const toolUses = turnToolUses(transcript); const assistantText = lastAssistantText(transcript); const result = decide({ toolUses, assistantText, override }); exitDecision(result); } catch { exitDecision({ block: false }); } } const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-coverage-verify.mjs'); if (isCli) main();