feat(enforce): T2 — Rule #5 memory-sync coverage gate (PreToolUse Edit/Write/MultiEdit)

This commit is contained in:
Дмитрий
2026-05-25 18:15:31 +03:00
parent a44d0a5c96
commit d85f291d6f
2 changed files with 169 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
/**
* Rule #5 — Memory write requires memory-sync coverage.
*
* PreToolUse hook on Edit / Write / MultiEdit. If the file_path looks like a
* memory store .md (memory/*.md or MEMORY.md), require the last assistant
* message to declare `coverage: direct:memory-sync` OR `coverage: skill:*` for
* a memory-related skill. Otherwise block with a re-announce instruction.
*
* Override phrase: `memory dump` in user's last prompt suppresses this rule.
*
* Spec: docs/superpowers/specs/2026-05-25-enforce-hard-rules-design.md
*/
import {
readStdin,
parseEventJson,
readTranscript,
lastUserPromptText,
lastAssistantText,
parseCoverageLine,
findOverride,
logOverride,
exitDecision,
isMemoryPath,
} from './enforce-hook-helpers.mjs';
const RULE_KEY = 'memory-sync-coverage';
function isMemorySyncCoverage(cov) {
if (!cov) return false;
if (cov.channel === 'direct' && /memory-sync/i.test(cov.id)) return true;
if (cov.channel === 'skill' && /memory/i.test(cov.id)) return true;
return false;
}
export function decide({ toolName, filePath, transcriptEntries, override }) {
if (!['Edit', 'Write', 'MultiEdit'].includes(toolName)) {
return { block: false };
}
if (!isMemoryPath(filePath)) return { block: false };
if (override) return { block: false };
const assistantText = lastAssistantText(transcriptEntries);
const cov = parseCoverageLine(assistantText);
if (isMemorySyncCoverage(cov)) return { block: false };
return {
block: true,
message: [
`[enforce-memory-coverage] Write to memory path requires memory-sync coverage tag.`,
`Detected coverage: ${cov ? cov.channel + ':' + cov.id : 'NONE'} (stale or absent).`,
``,
`Re-announce on a fresh assistant turn first:`,
` coverage: direct:memory-sync`,
`Then retry the Edit/Write.`,
``,
`Override: include the phrase "memory dump" in your prompt.`,
].join('\n'),
};
}
async function main() {
try {
const raw = await readStdin();
const event = parseEventJson(raw);
const toolName = event.tool_name || '';
const filePath = (event.tool_input && (event.tool_input.file_path || event.tool_input.notebook_path)) || '';
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 result = decide({ toolName, filePath, transcriptEntries: transcript, override });
exitDecision(result);
} catch {
// Fail-quiet on any internal error.
exitDecision({ block: false });
}
}
const isCli = process.argv[1] && process.argv[1].replace(/\\/g, '/').endsWith('/enforce-memory-coverage.mjs');
if (isCli) main();
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import { decide } from './enforce-memory-coverage.mjs';
function entries(userPrompt, assistantText) {
const out = [];
if (userPrompt) out.push({ message: { role: 'user', content: userPrompt } });
if (assistantText) out.push({ message: { role: 'assistant', content: [{ type: 'text', text: assistantText }] } });
return out;
}
describe('enforce-memory-coverage / decide', () => {
it('allows non-memory paths regardless of coverage', () => {
const r = decide({
toolName: 'Write',
filePath: 'tools/foo.mjs',
transcriptEntries: entries('do it', 'coverage: skill:tdd'),
});
expect(r.block).toBe(false);
});
it('blocks memory path with TDD coverage (stale)', () => {
const r = decide({
toolName: 'Edit',
filePath: 'C:\\Users\\x\\.claude\\projects\\proj\\memory\\foo.md',
transcriptEntries: entries('do', 'coverage: skill:superpowers:test-driven-development'),
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/memory-sync/);
});
it('blocks memory path with no coverage at all', () => {
const r = decide({
toolName: 'Write',
filePath: '/Users/x/.claude/projects/p/memory/x.md',
transcriptEntries: entries('do', 'no coverage line here'),
});
expect(r.block).toBe(true);
expect(r.message).toMatch(/NONE/);
});
it('allows memory path with direct:memory-sync coverage', () => {
const r = decide({
toolName: 'Edit',
filePath: 'C:\\Users\\x\\.claude\\projects\\proj\\memory\\foo.md',
transcriptEntries: entries('do', 'coverage: direct:memory-sync\nок'),
});
expect(r.block).toBe(false);
});
it('allows memory path with skill:memory-something coverage', () => {
const r = decide({
toolName: 'Edit',
filePath: '/x/.claude/projects/p/memory/foo.md',
transcriptEntries: entries('do', 'coverage: skill:memory-coordinator'),
});
expect(r.block).toBe(false);
});
it('allows memory path when override phrase present', () => {
const r = decide({
toolName: 'Write',
filePath: '/x/.claude/projects/p/memory/foo.md',
transcriptEntries: entries('memory dump please', 'no coverage'),
override: { phrase: 'memory dump', suppresses: ['memory-sync-coverage'] },
});
expect(r.block).toBe(false);
});
it('skips non-Edit/Write/MultiEdit tools', () => {
const r = decide({
toolName: 'Bash',
filePath: 'memory/x.md',
transcriptEntries: entries('do', 'no coverage'),
});
expect(r.block).toBe(false);
});
it('matches MEMORY.md anywhere', () => {
const r = decide({
toolName: 'Edit',
filePath: '/whatever/MEMORY.md',
transcriptEntries: entries('do', 'coverage: skill:tdd'),
});
expect(r.block).toBe(true);
});
});