Files
portal/tools/enforce-coverage-verify.mjs
T

102 lines
3.4 KiB
JavaScript

#!/usr/bin/env node
/**
* Rule #2 — Coverage tag verified against artifacts (Stop hook).
*
* Reads transcript at Stop event. Parses `coverage: <channel>:<id>` 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:<name> (e.g., skill:superpowers:test-driven-development)`,
` coverage: direct:<role> (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:<role> 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();