feat(enforce): T2 — Rule #5 memory-sync coverage gate (PreToolUse Edit/Write/MultiEdit)
This commit is contained in:
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user