b0cd18d797
Квирк 2: новый stripQuotedSpans делает детектор stdout/stderr-редиректа кавычко-осознанным — `>` / `2>` ВНУТРИ кавыченного аргумента (текст коммита с <email>, "2>1") больше не ложно-блокируется; настоящие редиректы (оператор вне кавычек) блокируются как прежде. RED→GREEN, существующие redirect/cd-app кейсы целы. 1A: убрана реклама мёртвых override-фраз (findOverride — заглушка v4, фразы не работают): баннер enforce-prompt-injection (каждый UserPromptSubmit) + block-сообщения enforce-verify-before-push / coverage-verify / memory-coverage / tdd-gate (×3). Каждый фикс залочен негативным тестом. Сознательно НЕ делали: калибровку 6 судьи (читать чат-контекст) и ослабление exact-match approve (квирк 3) — это рубежи защиты, их трогать нельзя. Регрессия vitest tools-only: 1989 passed | 2 skipped (verify через npx vitest run --root app --config vitest.config.tools.mjs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
100 lines
3.3 KiB
JavaScript
100 lines
3.3 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)`,
|
|
].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();
|