feat(m7-phase4a): coverage-verify — журнал-факт K2 (turn∩journal) + fail-CLOSE + снят override (§4.2)

coverage skill:X теперь требует X в ПЕРЕСЕЧЕНИИ «Skill-tool_use этого хода ∩ журнал
вызовов» (turn-scope от границы хода transcript, факт от журнала М1 через skillTakenByJournal
K2). Не по строке coverage: — Класс 1 закрыт; turn-scoping без false-pass (X из прошлого хода
в журнале, но не в transcript этого хода → block). direct/node/chain/hook/agent приняты на
этом слое (журналом не верифицируемы, §4.2). main обёрнут exitDisciplineDecision (fail-CLOSE
Фазы 0). Override-вокабуляр снят (§12 escape≠override). 9/9 тестов GREEN.
This commit is contained in:
Дмитрий
2026-06-08 11:07:35 +03:00
parent 3bd3caee40
commit 02e3ff7379
2 changed files with 99 additions and 74 deletions
+57 -53
View File
@@ -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: <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
* Stop-хук. Парсит `coverage: <channel>:<id>` из текста ассистента. Для 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:<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.`,
`[enforce-coverage-verify] ход выполнил мутирующие вызовы, но в ответе нет строки \`coverage:\`.`,
`Первой строкой следующего ответа:`,
` coverage: skill:<name> (навык должен быть РЕАЛЬНО вызван в этом ходе — проверяется по журналу)`,
` coverage: direct:<role> (например 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:<role> with justification.`,
`[enforce-coverage-verify] coverage skill:${cov.id} не подтверждён фактом.`,
inTurn
? `Навык НЕ найден в журнале вызовов (skill-журналер М1) — не доказано, что он реально отработал.`
: `Навык НЕ вызван Skill-инструментом в ЭТОМ ходе (журнал кумулятивен — нужен вызов в текущем ходе).`,
`Вызови навык через Skill, либо переключи coverage на direct:<role> с обоснованием.`,
].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');
+42 -21
View File
@@ -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);
});
});