From 37eb87f3512ec66ad5b388bb7df048426660e57a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 25 May 2026 18:19:03 +0300 Subject: [PATCH] =?UTF-8?q?feat(enforce):=20T5=20=E2=80=94=20Rule=20#2=20c?= =?UTF-8?q?overage-tag-verified-against-artifacts=20(Stop)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/enforce-coverage-verify.mjs | 101 +++++++++++++++++++++++++ tools/enforce-coverage-verify.test.mjs | 74 ++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 tools/enforce-coverage-verify.mjs create mode 100644 tools/enforce-coverage-verify.test.mjs diff --git a/tools/enforce-coverage-verify.mjs b/tools/enforce-coverage-verify.mjs new file mode 100644 index 00000000..6b7577fc --- /dev/null +++ b/tools/enforce-coverage-verify.mjs @@ -0,0 +1,101 @@ +#!/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(); diff --git a/tools/enforce-coverage-verify.test.mjs b/tools/enforce-coverage-verify.test.mjs new file mode 100644 index 00000000..aeecede1 --- /dev/null +++ b/tools/enforce-coverage-verify.test.mjs @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { decide } from './enforce-coverage-verify.mjs'; + +describe('enforce-coverage-verify / decide', () => { + it('allows turn with no mutating tools (pure conversational)', () => { + const r = decide({ toolUses: [{ name: 'Read', input: {} }], assistantText: 'just talking' }); + expect(r.block).toBe(false); + }); + + it('blocks mutating turn with no coverage line', () => { + const r = decide({ + toolUses: [{ name: 'Edit', input: { file_path: 'foo.mjs' } }], + assistantText: 'just did some work', + }); + expect(r.block).toBe(true); + expect(r.message).toMatch(/no.*coverage/); + }); + + it('blocks when coverage says skill but Skill tool not invoked', () => { + const r = decide({ + toolUses: [{ name: 'Edit', input: { file_path: 'foo.mjs' } }], + assistantText: 'coverage: skill:superpowers:test-driven-development\nдалее…', + }); + expect(r.block).toBe(true); + expect(r.message).toMatch(/Skill tool was never invoked/); + }); + + it('allows when coverage says skill and Skill tool invoked with matching name', () => { + const r = decide({ + toolUses: [ + { name: 'Skill', input: { skill: 'superpowers:test-driven-development' } }, + { name: 'Edit', input: { file_path: 'foo.mjs' } }, + ], + assistantText: 'coverage: skill:superpowers:test-driven-development\nок', + }); + expect(r.block).toBe(false); + }); + + it('allows when coverage matches without superpowers: prefix in tool input', () => { + const r = decide({ + toolUses: [ + { name: 'Skill', input: { skill: 'test-driven-development' } }, + { name: 'Edit', input: { file_path: 'foo.mjs' } }, + ], + assistantText: 'coverage: skill:superpowers:test-driven-development', + }); + expect(r.block).toBe(false); + }); + + it('allows direct coverage', () => { + const r = decide({ + toolUses: [{ name: 'Edit', input: { file_path: 'memory/foo.md' } }], + assistantText: 'coverage: direct:memory-sync', + }); + expect(r.block).toBe(false); + }); + + it('allows node coverage', () => { + const r = decide({ + toolUses: [{ name: 'Edit', input: { file_path: 'foo.vue' } }], + assistantText: 'coverage: node:#19', + }); + expect(r.block).toBe(false); + }); + + it('allows when override phrase present', () => { + const r = decide({ + toolUses: [{ name: 'Edit', input: { file_path: 'foo.mjs' } }], + assistantText: 'no coverage', + override: { phrase: 'без скилов', suppresses: ['coverage-skill-match'] }, + }); + expect(r.block).toBe(false); + }); +});