From d85f291d6f5bcf97da9b760b3fe2d0f79aef41c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 25 May 2026 18:15:31 +0300 Subject: [PATCH] =?UTF-8?q?feat(enforce):=20T2=20=E2=80=94=20Rule=20#5=20m?= =?UTF-8?q?emory-sync=20coverage=20gate=20(PreToolUse=20Edit/Write/MultiEd?= =?UTF-8?q?it)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/enforce-memory-coverage.mjs | 83 +++++++++++++++++++++++++ tools/enforce-memory-coverage.test.mjs | 86 ++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 tools/enforce-memory-coverage.mjs create mode 100644 tools/enforce-memory-coverage.test.mjs diff --git a/tools/enforce-memory-coverage.mjs b/tools/enforce-memory-coverage.mjs new file mode 100644 index 00000000..bc41e89d --- /dev/null +++ b/tools/enforce-memory-coverage.mjs @@ -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(); diff --git a/tools/enforce-memory-coverage.test.mjs b/tools/enforce-memory-coverage.test.mjs new file mode 100644 index 00000000..999691df --- /dev/null +++ b/tools/enforce-memory-coverage.test.mjs @@ -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); + }); +});