diff --git a/tools/enforce-coverage-verify.mjs b/tools/enforce-coverage-verify.mjs index 6b7577fc..509860ac 100644 --- a/tools/enforce-coverage-verify.mjs +++ b/tools/enforce-coverage-verify.mjs @@ -1,100 +1,104 @@ #!/usr/bin/env node /** - * Rule #2 — Coverage tag verified against artifacts (Stop hook). + * Rule #2 — Coverage = реальный навык, по ЖУРНАЛУ K2 (М7 Фаза 4a, §4.2). * - * 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 + * Stop-хук. Парсит `coverage: :` из текста ассистента. Для skill:-канала + * требует навык X в ПЕРЕСЕЧЕНИИ «Skill-tool_use ЭТОГО хода ∩ журнал вызовов» — turn-scope + * даёт граница хода transcript (turnToolUses), факт «реально отработал» — журнал (канал М1, + * skillTakenByJournal K2). Не по строке coverage: (Класс 1 закрыт). direct/node/chain/hook/agent + * журналом не верифицируемы (§4.2) — принимаются на этом слое (их судит думающий М4, §1 П3). * - * Override: "без скилов" / "direct ok" suppress this rule. + * fail-CLOSE (М7 Фаза 0 правило 1): любая внутренняя ошибка → блок. Override-вокабуляр снят + * (§12 escape≠override — единственная дверь escape М6). * - * 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 + * Spec: docs/superpowers/specs/2026-06-08-router-mentor-machine-7-design.md §4.2 */ import { readStdin, parseEventJson, readTranscript, - lastUserPromptText, lastAssistantText, parseCoverageLine, turnToolUses, - findOverride, - logOverride, - exitDecision, + runtimeDir, + exitDisciplineDecision, } from './enforce-hook-helpers.mjs'; +import { skillTakenByJournal } from './judge-gate-floor.mjs'; +import { loadJournal } from './action-journal.mjs'; +import { extractSkillCalls } from './enforce-skill-journaler.mjs'; -const RULE_KEY = 'coverage-skill-match'; +const MUTATING_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash']); -const MUTATING_TOOLS = new Set([ - 'Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash', -]); +/** Нормализация имени навыка: trim+lower, снять префикс superpowers:. */ +function normName(x) { + return String(x || '').trim().toLowerCase().replace(/^superpowers:/, ''); +} -export function decide({ - toolUses, assistantText, override, -}) { - // Pure conversational turn — skip. +export function decide({ toolUses = [], assistantText = '', journalSkillCalls = [] }) { + // Чисто-разговорный ход (нет мутирующих) — пропуск. 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.`, + `[enforce-coverage-verify] ход выполнил мутирующие вызовы, но в ответе нет строки \`coverage:\`.`, + `Первой строкой следующего ответа:`, + ` coverage: skill: (навык должен быть РЕАЛЬНО вызван в этом ходе — проверяется по журналу)`, + ` coverage: direct: (например direct:memory-sync)`, ].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) { + const want = normName(cov.id); + const turnSkillNames = toolUses + .filter((u) => u.name === 'Skill' && u.input && u.input.skill) + .map((u) => normName(u.input.skill)); + const inTurn = turnSkillNames.includes(want); + const inJournal = skillTakenByJournal({ + requiredSkills: [want], + journalSkillCalls: (journalSkillCalls || []).map(normName), + }).ok === true; + if (!(inTurn && inJournal)) { 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.`, + `[enforce-coverage-verify] coverage skill:${cov.id} не подтверждён фактом.`, + inTurn + ? `Навык НЕ найден в журнале вызовов (skill-журналер М1) — не доказано, что он реально отработал.` + : `Навык НЕ вызван Skill-инструментом в ЭТОМ ходе (журнал кумулятивен — нужен вызов в текущем ходе).`, + `Вызови навык через Skill, либо переключи coverage на direct: с обоснованием.`, ].join('\n'), }; } return { block: false }; } - // direct / node / chain / hook / agent — accepted at this layer. + // direct / node / chain / hook / agent — журналом не верифицируемы (§4.2), приняты здесь. 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 raw = await readStdin(); + const event = parseEventJson(raw); + await exitDisciplineDecision( + () => { + const transcript = readTranscript(event.transcript_path); + const toolUses = turnToolUses(transcript); + const assistantText = lastAssistantText(transcript); + const { entries } = loadJournal({ + sessionId: event.session_id || 'unknown', + runtimeDir: runtimeDir(), + }); + const journalSkillCalls = extractSkillCalls(entries); + return decide({ toolUses, assistantText, journalSkillCalls }); + }, + { label: 'enforce-coverage-verify' }, + ); } const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-coverage-verify.mjs'); diff --git a/tools/enforce-coverage-verify.test.mjs b/tools/enforce-coverage-verify.test.mjs index aeecede1..9cebb156 100644 --- a/tools/enforce-coverage-verify.test.mjs +++ b/tools/enforce-coverage-verify.test.mjs @@ -1,9 +1,11 @@ import { describe, it, expect } from 'vitest'; import { decide } from './enforce-coverage-verify.mjs'; -describe('enforce-coverage-verify / decide', () => { +const TDD = 'superpowers:test-driven-development'; + +describe('enforce-coverage-verify / decide (журнал-факт K2)', () => { it('allows turn with no mutating tools (pure conversational)', () => { - const r = decide({ toolUses: [{ name: 'Read', input: {} }], assistantText: 'just talking' }); + const r = decide({ toolUses: [{ name: 'Read', input: {} }], assistantText: 'just talking', journalSkillCalls: [] }); expect(r.block).toBe(false); }); @@ -11,46 +13,63 @@ describe('enforce-coverage-verify / decide', () => { const r = decide({ toolUses: [{ name: 'Edit', input: { file_path: 'foo.mjs' } }], assistantText: 'just did some work', + journalSkillCalls: [], }); expect(r.block).toBe(true); - expect(r.message).toMatch(/no.*coverage/); + expect(r.message).toMatch(/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', () => { + it('blocks skill:X when Skill tool_use present this turn but X NOT in journal (acceptance)', () => { const r = decide({ toolUses: [ - { name: 'Skill', input: { skill: 'superpowers:test-driven-development' } }, + { name: 'Skill', input: { skill: TDD } }, { name: 'Edit', input: { file_path: 'foo.mjs' } }, ], - assistantText: 'coverage: skill:superpowers:test-driven-development\nок', + assistantText: `coverage: skill:${TDD}\nок`, + journalSkillCalls: [], + }); + expect(r.block).toBe(true); + expect(r.message).toMatch(/журнал/i); + }); + + it('blocks skill:X when X in journal but NOT in this turn tool_use (turn-scoping, no false-pass)', () => { + const r = decide({ + toolUses: [{ name: 'Edit', input: { file_path: 'foo.mjs' } }], + assistantText: `coverage: skill:${TDD}`, + journalSkillCalls: [TDD], + }); + expect(r.block).toBe(true); + }); + + it('allows skill:X when X in BOTH this-turn tool_use AND journal', () => { + const r = decide({ + toolUses: [ + { name: 'Skill', input: { skill: TDD } }, + { name: 'Edit', input: { file_path: 'foo.mjs' } }, + ], + assistantText: `coverage: skill:${TDD}\nок`, + journalSkillCalls: [TDD], }); expect(r.block).toBe(false); }); - it('allows when coverage matches without superpowers: prefix in tool input', () => { + it('matches skill name across superpowers: prefix (tool input bare, coverage prefixed, journal prefixed)', () => { 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', + assistantText: `coverage: skill:${TDD}`, + journalSkillCalls: [TDD], }); expect(r.block).toBe(false); }); - it('allows direct coverage', () => { + it('allows direct coverage (journal cannot verify non-skill channels — §4.2)', () => { const r = decide({ toolUses: [{ name: 'Edit', input: { file_path: 'memory/foo.md' } }], assistantText: 'coverage: direct:memory-sync', + journalSkillCalls: [], }); expect(r.block).toBe(false); }); @@ -59,16 +78,18 @@ describe('enforce-coverage-verify / decide', () => { const r = decide({ toolUses: [{ name: 'Edit', input: { file_path: 'foo.vue' } }], assistantText: 'coverage: node:#19', + journalSkillCalls: [], }); expect(r.block).toBe(false); }); - it('allows when override phrase present', () => { + it('override phrase does NOT bypass (§12 escape≠override — vocab removed)', () => { const r = decide({ toolUses: [{ name: 'Edit', input: { file_path: 'foo.mjs' } }], assistantText: 'no coverage', - override: { phrase: 'без скилов', suppresses: ['coverage-skill-match'] }, + journalSkillCalls: [], + override: { phrase: 'без скилов' }, }); - expect(r.block).toBe(false); + expect(r.block).toBe(true); }); });