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:
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user