myxce/.wolf/hooks/pre-write.js
2026-08-27 08:27:43 +00:00

147 lines
No EOL
6.6 KiB
JavaScript

import * as fs from "node:fs";
import * as path from "node:path";
import { getWolfDir, ensureWolfDir, readBugLogFile, readMarkdown, readStdin, emitHookJSON, recordInjectionToSessionFile, hookMain, getSessionFilePath } from "./shared.js";
import { searchBugsFTS } from "./bug-index.js";
async function main() {
ensureWolfDir();
const wolfDir = getWolfDir();
const raw = await readStdin();
let input;
try {
input = JSON.parse(raw);
}
catch {
return;
}
// For Edit tool, the meaningful content is old_string + new_string
const content = input.tool_input?.content ?? "";
const oldStr = input.tool_input?.old_string ?? "";
const newStr = input.tool_input?.new_string ?? "";
const filePath = input.tool_input?.file_path ?? input.tool_input?.path ?? "";
const allContent = [content, oldStr, newStr].join("\n");
if (!allContent.trim()) {
return;
}
// Model-visible notes — flushed as ONE additionalContext at exit.
const notes = [];
// 1. Cerebrum Do-Not-Repeat check
notes.push(...checkCerebrum(wolfDir, allContent));
// 2. Bug log: search for similar past bugs when editing code
// This fires when Claude is about to edit a file — if the edit looks like a fix
// (changing error handling, modifying catch blocks, etc.), check the bug log
if (filePath && (oldStr || content)) {
notes.push(...checkBugLog(wolfDir, filePath, oldStr, newStr, content));
}
if (notes.length > 0) {
recordInjectionToSessionFile(getSessionFilePath(input), "cerebrum_buglog", notes.join("\n"));
emitHookJSON("PreToolUse", { additionalContext: notes.join("\n") });
}
}
function checkCerebrum(wolfDir, content) {
const cerebrumContent = readMarkdown(path.join(wolfDir, "cerebrum.md"));
const doNotRepeatSection = cerebrumContent.split("## Do-Not-Repeat")[1];
if (!doNotRepeatSection)
return [];
const entries = doNotRepeatSection.split("## ")[0];
const lines = entries.split("\n").filter((l) => l.trim().startsWith("[") || l.trim().startsWith("-"));
const warnings = [];
for (const line of lines) {
const trimmed = line.trim().replace(/^[-*]\s*/, "").replace(/^\[[\d-]+\]\s*/, "");
if (!trimmed)
continue;
const patterns = [];
const quotedMatches = trimmed.match(/"([^"]+)"/g) || trimmed.match(/'([^']+)'/g) || trimmed.match(/`([^`]+)`/g);
if (quotedMatches) {
for (const qm of quotedMatches) {
patterns.push(qm.replace(/["'`]/g, ""));
}
}
const neverMatch = trimmed.match(/(?:never use|avoid|don't use|do not use)\s+(\w+)/i);
if (neverMatch)
patterns.push(neverMatch[1]);
for (const pattern of patterns) {
try {
const regex = new RegExp(`\\b${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
if (regex.test(content)) {
warnings.push(`OpenWolf cerebrum warning: "${trimmed}". Check this edit against that rule before proceeding.`);
break;
}
}
catch { }
}
}
return warnings.slice(0, 3);
}
// Common words that appear in most code — must be excluded from similarity matching
const STOP_WORDS = new Set([
"error", "function", "return", "const", "this", "that", "with", "from",
"import", "export", "class", "interface", "type", "undefined", "null",
"true", "false", "string", "number", "object", "array", "value",
"file", "path", "name", "data", "response", "request", "result",
"should", "must", "does", "have", "been", "will", "would", "could",
"when", "then", "else", "each", "some", "every", "only",
]);
function checkBugLog(wolfDir, filePath, oldStr, newStr, content) {
const bugLogPath = path.join(wolfDir, "buglog.json");
if (!fs.existsSync(bugLogPath))
return [];
const bugLog = readBugLogFile(wolfDir);
if (bugLog.bugs.length === 0)
return [];
const basename = path.basename(filePath);
const editText = (oldStr + " " + newStr + " " + content).toLowerCase();
// J4: signature-keyed FTS retrieval first — finds relevant fixes across
// files, ranked by relevance, not just same-basename matches. Falls back to
// the legacy basename + overlap filter when node:sqlite is unavailable.
let relevant = null;
const ftsHits = searchBugsFTS(wolfDir, `${basename} ${editText.slice(0, 600)}`, 4);
if (ftsHits !== null) {
// Precision gate on the recall-oriented OR-query: keep same-file hits, and
// cross-file hits only with a tag or 3-word overlap with the edit.
const editTokens = tokenize(editText);
relevant = ftsHits.filter((bug) => {
if (path.basename(bug.file ?? "") === basename)
return true;
const tagHit = (bug.tags ?? []).some((t) => editText.includes(t.toLowerCase()));
if (tagHit)
return true;
const bugTokens = tokenize(bug.error_message + " " + bug.root_cause);
return [...editTokens].filter((t) => bugTokens.has(t)).length >= 3;
});
}
if (relevant === null) {
// Legacy path: ONLY surface bugs recorded against the SAME file.
const fileMatches = bugLog.bugs.filter(b => {
const bugBasename = path.basename(b.file);
return bugBasename === basename;
});
if (fileMatches.length === 0)
return [];
const editTokens = tokenize(editText);
relevant = fileMatches.filter(bug => {
const tagHit = bug.tags.some(t => editText.includes(t.toLowerCase()));
if (tagHit)
return true;
const bugTokens = tokenize(bug.error_message + " " + bug.root_cause);
const overlap = [...editTokens].filter(t => bugTokens.has(t));
return overlap.length >= 3;
});
}
if (relevant.length === 0)
return [];
// Surface as a FYI, not a directive — Claude should evaluate, not blindly apply
const lines = [
`OpenWolf buglog: ${relevant.length} past bug(s) recorded for ${basename}. Review for context; do NOT apply blindly:`,
];
for (const bug of relevant.slice(0, 2)) {
lines.push(`[${bug.id}] "${bug.error_message.slice(0, 70)}" | Cause: ${bug.root_cause.slice(0, 80)} | Fix: ${bug.fix.slice(0, 80)}`);
}
return lines;
}
function tokenize(text) {
return new Set(text.replace(/[^\w\s]/g, " ").split(/\s+/)
.filter(w => w.length > 3 && !STOP_WORDS.has(w.toLowerCase()))
.map(w => w.toLowerCase()));
}
hookMain("pre-write", main);
//# sourceMappingURL=pre-write.js.map