Initial commit: PDF→static HTML/CSS site generator

main.py converts every PDF in examples/ into a browseable, mobile-responsive
HTML archive under output/ using poppler-utils. Includes the two NETgazet
sample PDFs, project metadata, OpenWolf scaffolding, and README covering
usage, a watch-folder script, and WordPress iframe embedding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 16:00:07 +02:00
co-authored by Claude Opus 4.7
commit 93174e1e09
33 changed files with 3652 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
{
"session_id": "session-2026-06-03-1555",
"started": "2026-06-03T13:55:38.248Z",
"files_read": {
"/home/seppe4/vscode_projects/netgazet/output/index.html": {
"count": 1,
"tokens": 0,
"first_read": "2026-06-03T13:56:13.931Z"
},
"/home/seppe4/vscode_projects/netgazet/.gitignore": {
"count": 1,
"tokens": 0,
"first_read": "2026-06-03T13:57:08.826Z"
}
},
"files_written": [
{
"file": "/home/seppe4/vscode_projects/netgazet/README.md",
"action": "create",
"tokens": 1039,
"at": "2026-06-03T13:57:02.392Z"
},
{
"file": "/home/seppe4/vscode_projects/netgazet/.gitignore",
"action": "create",
"tokens": 59,
"at": "2026-06-03T13:57:12.946Z"
}
],
"edit_counts": {
"README.md": 1,
".gitignore": 1
},
"anatomy_hits": 0,
"anatomy_misses": 2,
"repeated_reads_warned": 0,
"cerebrum_warnings": 0,
"stop_count": 1
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+69
View File
@@ -0,0 +1,69 @@
import * as path from "node:path";
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, parseAnatomy, estimateTokens, readStdin, normalizePath } from "./shared.js";
async function main() {
ensureWolfDir();
const wolfDir = getWolfDir();
const hooksDir = path.join(wolfDir, "hooks");
const sessionFile = path.join(hooksDir, "_session.json");
const raw = await readStdin();
let input;
try {
input = JSON.parse(raw);
}
catch {
process.exit(0);
return;
}
const filePath = input.tool_input?.file_path ?? input.tool_input?.path ?? "";
const content = input.tool_output?.content ?? "";
if (!filePath) {
process.exit(0);
return;
}
const normalizedFile = normalizePath(filePath);
// Skip tracking for .wolf/ internal files — consistent with pre-read
const projectDir = normalizePath(process.env.CLAUDE_PROJECT_DIR || process.cwd());
const relToProject = normalizedFile.startsWith(projectDir)
? normalizedFile.slice(projectDir.length).replace(/^\//, "")
: "";
if (relToProject.startsWith(".wolf/") || relToProject.startsWith(".wolf\\")) {
process.exit(0);
return;
}
const ext = path.extname(filePath).toLowerCase();
const codeExts = new Set([".ts", ".js", ".tsx", ".jsx", ".py", ".rs", ".go", ".java", ".c", ".cpp", ".css", ".json", ".yaml", ".yml"]);
const proseExts = new Set([".md", ".txt", ".rst"]);
const type = codeExts.has(ext) ? "code" : proseExts.has(ext) ? "prose" : "mixed";
let tokens = content ? estimateTokens(content, type) : 0;
// Fallback: if tool_output had no content, use anatomy token estimate
if (tokens === 0) {
const anatomyContent = readMarkdown(path.join(wolfDir, "anatomy.md"));
const sections = parseAnatomy(anatomyContent);
for (const [sectionKey, entries] of sections) {
for (const entry of entries) {
const entryRelPath = normalizePath(path.join(sectionKey, entry.file));
if (normalizedFile.endsWith(entryRelPath) || normalizedFile.endsWith("/" + entryRelPath)) {
tokens = entry.tokens;
break;
}
}
if (tokens > 0)
break;
}
}
const session = readJSON(sessionFile, { files_read: {} });
if (session.files_read[normalizedFile]) {
session.files_read[normalizedFile].tokens = tokens;
}
else {
session.files_read[normalizedFile] = {
count: 1,
tokens,
first_read: new Date().toISOString(),
};
}
writeJSON(sessionFile, session);
process.exit(0);
}
main().catch(() => process.exit(0));
//# sourceMappingURL=post-read.js.map
+503
View File
@@ -0,0 +1,503 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as crypto from "node:crypto";
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, parseAnatomy, serializeAnatomy, extractDescription, estimateTokens, appendMarkdown, timeShort, readStdin, normalizePath } from "./shared.js";
async function main() {
ensureWolfDir();
const wolfDir = getWolfDir();
const hooksDir = path.join(wolfDir, "hooks");
const sessionFile = path.join(hooksDir, "_session.json");
const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
const raw = await readStdin();
let input;
try {
input = JSON.parse(raw);
}
catch {
process.exit(0);
return;
}
const toolName = input.tool_name ?? "Write";
const filePath = input.tool_input?.file_path ?? input.tool_input?.path ?? "";
if (!filePath) {
process.exit(0);
return;
}
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath);
// Skip processing for .wolf/ internal files to avoid slow self-referential updates
const relPath = normalizePath(path.relative(projectRoot, absolutePath));
if (relPath.startsWith(".wolf/")) {
process.exit(0);
return;
}
// Never track .env files in anatomy — they contain secrets
const baseName = path.basename(absolutePath);
if (baseName === ".env" || baseName.startsWith(".env.")) {
process.exit(0);
return;
}
const oldStr = input.tool_input?.old_string ?? "";
const newStr = input.tool_input?.new_string ?? "";
// 1. Update anatomy.md
try {
const anatomyPath = path.join(wolfDir, "anatomy.md");
let anatomyContent;
try {
anatomyContent = fs.readFileSync(anatomyPath, "utf-8");
}
catch {
anatomyContent = "# anatomy.md\n\n> Auto-maintained by OpenWolf.\n";
}
const sections = parseAnatomy(anatomyContent);
const relPathLocal = normalizePath(path.relative(projectRoot, absolutePath));
const dir = path.dirname(relPathLocal);
const fileName = path.basename(relPathLocal);
const sectionKey = dir === "." ? "./" : dir + "/";
let fileContent = "";
try {
fileContent = fs.readFileSync(absolutePath, "utf-8");
}
catch {
fileContent = input.tool_input?.content ?? "";
}
const desc = extractDescription(absolutePath).slice(0, 100);
const ext = path.extname(absolutePath).toLowerCase();
const codeExts = new Set([".ts", ".js", ".tsx", ".jsx", ".py", ".json", ".yaml", ".yml", ".css"]);
const proseExts = new Set([".md", ".txt", ".rst"]);
const type = codeExts.has(ext) ? "code" : proseExts.has(ext) ? "prose" : "mixed";
const tokens = estimateTokens(fileContent, type);
if (!sections.has(sectionKey))
sections.set(sectionKey, []);
const entries = sections.get(sectionKey);
const idx = entries.findIndex((e) => e.file === fileName);
if (idx !== -1) {
entries[idx] = { file: fileName, description: desc, tokens };
}
else {
entries.push({ file: fileName, description: desc, tokens });
}
let fileCount = 0;
for (const [, list] of sections)
fileCount += list.length;
const serialized = serializeAnatomy(sections, {
lastScanned: new Date().toISOString(),
fileCount,
hits: 0,
misses: 0,
});
const tmp = anatomyPath + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
try {
fs.writeFileSync(tmp, serialized, "utf-8");
fs.renameSync(tmp, anatomyPath);
}
catch {
try {
fs.writeFileSync(anatomyPath, serialized, "utf-8");
}
catch { }
try {
fs.unlinkSync(tmp);
}
catch { }
}
}
catch { }
// 2. Append richer entry to memory.md
try {
const action = toolName === "Write" ? "Created" : toolName === "MultiEdit" ? "Multi-edited" : "Edited";
const relFile = normalizePath(path.relative(projectRoot, absolutePath));
const fileContent = input.tool_input?.content ?? "";
const ext = path.extname(absolutePath).toLowerCase();
const codeExts = new Set([".ts", ".js", ".tsx", ".jsx", ".py", ".json", ".yaml", ".yml", ".css"]);
const type = codeExts.has(ext) ? "code" : "mixed";
const writeTokens = estimateTokens(fileContent || newStr, type);
let changeDesc = "";
if (oldStr && newStr) {
changeDesc = summarizeEdit(oldStr, newStr, baseName);
}
const memoryPath = path.join(wolfDir, "memory.md");
const outcome = changeDesc || "—";
appendMarkdown(memoryPath, `| ${timeShort()} | ${action} ${relFile} | ${outcome} | ~${writeTokens} |\n`);
}
catch { }
// 3. Record in session tracker + track edit counts
try {
const session = readJSON(sessionFile, { files_written: [], edit_counts: {} });
if (!session.edit_counts)
session.edit_counts = {};
const normalizedFile = normalizePath(filePath);
const action = toolName === "Write" ? "create" : "edit";
const fileContent = input.tool_input?.content ?? "";
const tokens = estimateTokens(fileContent || newStr, "code");
session.files_written.push({
file: normalizedFile,
action,
tokens,
at: new Date().toISOString(),
});
const editKey = normalizePath(path.relative(projectRoot, absolutePath));
session.edit_counts[editKey] = (session.edit_counts[editKey] || 0) + 1;
writeJSON(sessionFile, session);
if (session.edit_counts[editKey] >= 3) {
process.stderr.write(`⚠️ OpenWolf: ${baseName} has been edited ${session.edit_counts[editKey]} times this session. If you're fixing a bug, remember to log it to .wolf/buglog.json.\n`);
}
}
catch { }
// 4. Auto-detect bug-fix patterns and log them
try {
if (oldStr && newStr) {
autoDetectBugFix(wolfDir, absolutePath, projectRoot, oldStr, newStr);
}
}
catch { }
process.exit(0);
}
// ─── Edit Summarizer ─────────────────────────────────────────────
function summarizeEdit(oldStr, newStr, filename) {
const oldLines = oldStr.split("\n");
const newLines = newStr.split("\n");
const oldCount = oldLines.length;
const newCount = newLines.length;
const ext = path.extname(filename).toLowerCase();
// --- Structural fixes ---
if (newStr.includes("try") && newStr.includes("catch") && !oldStr.includes("catch")) {
return "added error handling";
}
if (newStr.includes("?.") && !oldStr.includes("?."))
return "added optional chaining";
if (newStr.includes("?? ") && !oldStr.includes("?? "))
return "added nullish coalescing";
// --- Deleted code ---
if (!newStr.trim() || newStr.trim().length < oldStr.trim().length * 0.2) {
return `removed ${oldCount} lines`;
}
// --- Import changes ---
const oldImports = oldLines.filter(l => /^\s*(import|require|use |from )/.test(l)).length;
const newImports = newLines.filter(l => /^\s*(import|require|use |from )/.test(l)).length;
if (newImports > oldImports && Math.abs(newCount - oldCount) <= newImports - oldImports + 1) {
return `added ${newImports - oldImports} import(s)`;
}
// --- Value/string replacement (common bug fix: wrong value) ---
if (oldCount === 1 && newCount === 1) {
const o = oldStr.trim();
const n = newStr.trim();
// String literal change
const oStr = o.match(/['"`]([^'"`]+)['"`]/);
const nStr = n.match(/['"`]([^'"`]+)['"`]/);
if (oStr && nStr && oStr[1] !== nStr[1]) {
return `"${oStr[1].slice(0, 25)}" → "${nStr[1].slice(0, 25)}"`;
}
// Number change
const oNum = o.match(/\b(\d+\.?\d*)\b/);
const nNum = n.match(/\b(\d+\.?\d*)\b/);
if (oNum && nNum && oNum[1] !== nNum[1] && o.replace(oNum[1], "") === n.replace(nNum[1], "")) {
return `${oNum[1]}${nNum[1]}`;
}
return "inline fix";
}
// --- Method/function call changes ---
const oldCalls = extractCalls(oldStr);
const newCalls = extractCalls(newStr);
const addedCalls = newCalls.filter(c => !oldCalls.includes(c));
const removedCalls = oldCalls.filter(c => !newCalls.includes(c));
if (removedCalls.length === 1 && addedCalls.length === 1) {
return `${removedCalls[0]}() → ${addedCalls[0]}()`;
}
// --- CSS/style changes ---
if (ext === ".css" || ext === ".scss" || ext === ".vue" || ext === ".tsx" || ext === ".jsx") {
const oldProps = (oldStr.match(/[\w-]+\s*:/g) || []).map(p => p.replace(/\s*:/, ""));
const newProps = (newStr.match(/[\w-]+\s*:/g) || []).map(p => p.replace(/\s*:/, ""));
const changed = newProps.filter(p => !oldProps.includes(p));
if (changed.length > 0 && changed.length <= 3) {
return `CSS: ${changed.join(", ")}`;
}
}
// --- Condition changes ---
const oldConds = (oldStr.match(/if\s*\(([^)]+)\)/g) || []);
const newConds = (newStr.match(/if\s*\(([^)]+)\)/g) || []);
if (newConds.length > oldConds.length) {
return `added ${newConds.length - oldConds.length} condition(s)`;
}
// --- Function modified ---
const fnMatch = newStr.match(/(?:function|def|fn|func|async\s+function)\s+(\w+)/);
if (fnMatch) {
return `modified ${fnMatch[1]}()`;
}
// --- Class/method context ---
const methodMatch = newStr.match(/(?:public|private|protected)?\s*(?:async\s+)?(\w+)\s*\([^)]*\)\s*[:{]/);
if (methodMatch) {
return `modified ${methodMatch[1]}()`;
}
// --- Size-based fallback ---
if (newCount > oldCount + 5)
return `expanded (+${newCount - oldCount} lines)`;
if (oldCount > newCount + 5)
return `reduced (-${oldCount - newCount} lines)`;
return `${oldCount}${newCount} lines`;
}
function extractCalls(code) {
return [...new Set((code.match(/(\w+)\s*\(/g) || [])
.map(m => m.match(/(\w+)/)?.[1] || "")
.filter(n => n.length > 2 && !["if", "for", "while", "switch", "catch", "function", "return", "new", "typeof", "instanceof", "const", "let", "var"].includes(n)))];
}
// ─── Auto Bug Detection ──────────────────────────────────────────
function autoDetectBugFix(wolfDir, absolutePath, projectRoot, oldStr, newStr) {
const bugLogPath = path.join(wolfDir, "buglog.json");
const bugLog = readJSON(bugLogPath, { version: 1, bugs: [] });
const relFile = normalizePath(path.relative(projectRoot, absolutePath));
const basename = path.basename(absolutePath);
const ext = path.extname(basename).toLowerCase();
// Detect what kind of fix this is
const detection = detectFixPattern(oldStr, newStr, ext);
if (!detection)
return;
// Check for recent duplicate (same file + same category within 5 min)
const recentDupe = bugLog.bugs.find(b => {
if (path.basename(b.file) !== basename)
return false;
if (!b.tags.includes("auto-detected"))
return false;
if (!b.tags.includes(detection.category))
return false;
const bugTime = new Date(b.last_seen).getTime();
return (Date.now() - bugTime) < 5 * 60 * 1000;
});
if (recentDupe) {
recentDupe.occurrences++;
recentDupe.last_seen = new Date().toISOString();
// Append additional context
if (detection.context && !recentDupe.fix.includes(detection.context)) {
recentDupe.fix += ` | Also: ${detection.context}`;
}
writeJSON(bugLogPath, bugLog);
return;
}
const nextId = `bug-${String(bugLog.bugs.length + 1).padStart(3, "0")}`;
bugLog.bugs.push({
id: nextId,
timestamp: new Date().toISOString(),
error_message: detection.summary,
file: relFile,
root_cause: detection.rootCause,
fix: detection.fix,
tags: ["auto-detected", detection.category, ext.replace(".", "") || "unknown"],
related_bugs: [],
occurrences: 1,
last_seen: new Date().toISOString(),
});
writeJSON(bugLogPath, bugLog);
}
function detectFixPattern(oldStr, newStr, ext) {
const oldLines = oldStr.split("\n");
const newLines = newStr.split("\n");
// --- Error handling added ---
if (newStr.includes("catch") && !oldStr.includes("catch")) {
const fn = newStr.match(/(?:function|def|async)\s+(\w+)/)?.[1] || "unknown";
return {
category: "error-handling",
summary: `Missing error handling in ${path.basename(fn)}`,
rootCause: "Code path had no error handling — exceptions would propagate uncaught",
fix: `Added try/catch block`,
context: extractChangedLines(oldStr, newStr),
};
}
// --- Null/undefined safety ---
if ((newStr.includes("?.") && !oldStr.includes("?.")) ||
(newStr.includes("?? ") && !oldStr.includes("?? ")) ||
(/!==?\s*(null|undefined)/.test(newStr) && !/!==?\s*(null|undefined)/.test(oldStr))) {
return {
category: "null-safety",
summary: `Null/undefined access in ${path.basename(path.basename(""))}`,
rootCause: "Property access on potentially null/undefined value",
fix: `Added null safety (optional chaining or null check)`,
context: extractChangedLines(oldStr, newStr),
};
}
// --- Guard clause / early return added ---
if (/if\s*\([^)]*\)\s*(return|throw|continue|break)/.test(newStr) &&
!/if\s*\([^)]*\)\s*(return|throw|continue|break)/.test(oldStr)) {
const condition = newStr.match(/if\s*\(([^)]+)\)/)?.[1]?.trim().slice(0, 60) || "condition";
return {
category: "guard-clause",
summary: `Missing guard clause`,
rootCause: `No early return/throw for edge case: ${condition}`,
fix: `Added guard clause: if (${condition.slice(0, 40)})`,
};
}
// --- Wrong value / string fix (very common bug) ---
if (oldLines.length <= 3 && newLines.length <= 3) {
const oldJoined = oldStr.trim();
const newJoined = newStr.trim();
// String literal changed
const oStrs = oldJoined.match(/['"`]([^'"`]{2,})['"`]/g) || [];
const nStrs = newJoined.match(/['"`]([^'"`]{2,})['"`]/g) || [];
if (oStrs.length > 0 && nStrs.length > 0) {
for (let i = 0; i < Math.min(oStrs.length, nStrs.length); i++) {
if (oStrs[i] !== nStrs[i]) {
return {
category: "wrong-value",
summary: `Incorrect value in code`,
rootCause: `Had ${oStrs[i].slice(0, 50)}`,
fix: `Changed to ${nStrs[i].slice(0, 50)}`,
};
}
}
}
// Variable name / method call changed
const oldTokens = tokenizeCode(oldJoined);
const newTokens = tokenizeCode(newJoined);
const changed = [];
for (let i = 0; i < Math.min(oldTokens.length, newTokens.length); i++) {
if (oldTokens[i] !== newTokens[i]) {
changed.push([oldTokens[i], newTokens[i]]);
}
}
if (changed.length === 1 && changed[0][0].length > 2) {
return {
category: "wrong-reference",
summary: `Wrong reference: ${changed[0][0]} should be ${changed[0][1]}`,
rootCause: `Used "${changed[0][0]}" instead of "${changed[0][1]}"`,
fix: `Changed ${changed[0][0]}${changed[0][1]}`,
};
}
}
// --- Logic fix (condition changed) ---
const oldCond = oldStr.match(/if\s*\(([^)]+)\)/)?.[1];
const newCond = newStr.match(/if\s*\(([^)]+)\)/)?.[1];
if (oldCond && newCond && oldCond !== newCond && oldLines.length <= 5) {
return {
category: "logic-fix",
summary: `Wrong condition in logic`,
rootCause: `Condition was: if (${oldCond.slice(0, 50)})`,
fix: `Changed to: if (${newCond.slice(0, 50)})`,
};
}
// --- Operator fix (=== vs ==, > vs >=, etc.) ---
const opChange = findOperatorChange(oldStr, newStr);
if (opChange) {
return {
category: "operator-fix",
summary: `Wrong operator: ${opChange.old} should be ${opChange.new}`,
rootCause: `Used "${opChange.old}" instead of "${opChange.new}"`,
fix: `Changed operator ${opChange.old}${opChange.new}`,
};
}
// --- Missing import/require ---
const oldImports = new Set((oldStr.match(/(?:import|require)\s*\(?['"]([^'"]+)['"]\)?/g) || []).map(m => m));
const newImports = (newStr.match(/(?:import|require)\s*\(?['"]([^'"]+)['"]\)?/g) || []);
const addedImports = newImports.filter(i => !oldImports.has(i));
if (addedImports.length > 0 && newLines.length - oldLines.length <= addedImports.length + 2) {
const modules = addedImports.map(i => i.match(/['"]([^'"]+)['"]/)?.[1] || "").filter(Boolean);
return {
category: "missing-import",
summary: `Missing import: ${modules.join(", ")}`,
rootCause: `Module(s) not imported: ${modules.join(", ")}`,
fix: `Added import(s) for ${modules.join(", ")}`,
};
}
// --- Return value fix ---
const oldReturn = oldStr.match(/return\s+(.+)/)?.[1]?.trim();
const newReturn = newStr.match(/return\s+(.+)/)?.[1]?.trim();
if (oldReturn && newReturn && oldReturn !== newReturn && oldLines.length <= 5) {
return {
category: "return-value",
summary: `Wrong return value`,
rootCause: `Was returning: ${oldReturn.slice(0, 50)}`,
fix: `Now returns: ${newReturn.slice(0, 50)}`,
};
}
// --- Async/await fix ---
if (newStr.includes("await ") && !oldStr.includes("await ")) {
return {
category: "async-fix",
summary: `Missing await`,
rootCause: `Async call without await — returned Promise instead of value`,
fix: `Added await to async call`,
context: extractChangedLines(oldStr, newStr),
};
}
if (newStr.includes("async ") && !oldStr.includes("async ")) {
return {
category: "async-fix",
summary: `Function not marked async`,
rootCause: `Function uses await but wasn't declared async`,
fix: `Added async modifier`,
};
}
// --- Type annotation/cast fix ---
if (ext === ".ts" || ext === ".tsx") {
if ((newStr.includes(" as ") && !oldStr.includes(" as ")) ||
(newStr.includes(": ") && !oldStr.includes(": ") && oldLines.length <= 3)) {
return {
category: "type-fix",
summary: `Type error`,
rootCause: `Missing or incorrect type annotation`,
fix: `Added type assertion/annotation`,
context: extractChangedLines(oldStr, newStr),
};
}
}
// --- CSS/style fix ---
if (ext === ".css" || ext === ".scss" || ext === ".vue" || ext === ".tsx" || ext === ".jsx") {
const oldProps = extractCSSProps(oldStr);
const newProps = extractCSSProps(newStr);
const changedProps = [...newProps.entries()].filter(([k, v]) => oldProps.get(k) !== v && oldProps.has(k));
if (changedProps.length > 0 && changedProps.length <= 3) {
const desc = changedProps.map(([k, v]) => `${k}: ${oldProps.get(k)}${v}`).join("; ");
return {
category: "style-fix",
summary: `CSS fix: ${changedProps.map(([k]) => k).join(", ")}`,
rootCause: desc,
fix: `Changed ${desc}`,
};
}
}
// --- Significant diff (catch-all for substantial edits) ---
const diffRatio = Math.abs(newStr.length - oldStr.length) / Math.max(oldStr.length, 1);
if (diffRatio > 0.3 && oldLines.length >= 3 && newLines.length >= 3) {
// Only log if there's meaningful structural change, not just additions
const removedLines = oldLines.filter(l => l.trim() && !newLines.some(nl => nl.trim() === l.trim()));
if (removedLines.length >= 2) {
return {
category: "refactor",
summary: `Significant refactor of ${path.basename("")}`,
rootCause: `${removedLines.length} lines replaced/restructured`,
fix: `Rewrote ${oldLines.length}${newLines.length} lines (${removedLines.length} removed)`,
context: removedLines.slice(0, 2).map(l => l.trim().slice(0, 50)).join("; "),
};
}
}
return null;
}
function extractChangedLines(oldStr, newStr) {
const oldLines = new Set(oldStr.split("\n").map(l => l.trim()).filter(Boolean));
const newLines = newStr.split("\n").map(l => l.trim()).filter(Boolean);
const added = newLines.filter(l => !oldLines.has(l));
return added.slice(0, 2).map(l => l.slice(0, 60)).join("; ");
}
function tokenizeCode(code) {
return code.replace(/[^\w$]/g, " ").split(/\s+/).filter(t => t.length > 0);
}
function findOperatorChange(oldStr, newStr) {
const operators = ["===", "!==", "==", "!=", ">=", "<=", ">>", "<<", "&&", "||", "??"];
for (const op of operators) {
if (oldStr.includes(op) && !newStr.includes(op)) {
for (const op2 of operators) {
if (op2 !== op && newStr.includes(op2) && !oldStr.includes(op2)) {
return { old: op, new: op2 };
}
}
}
}
return null;
}
function extractCSSProps(code) {
const props = new Map();
const matches = code.matchAll(/([\w-]+)\s*:\s*([^;}\n]+)/g);
for (const m of matches) {
props.set(m[1].trim(), m[2].trim());
}
return props;
}
main().catch(() => process.exit(0));
//# sourceMappingURL=post-write.js.map
+80
View File
@@ -0,0 +1,80 @@
import * as path from "node:path";
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, parseAnatomy, readStdin, normalizePath } from "./shared.js";
async function main() {
ensureWolfDir();
const wolfDir = getWolfDir();
const hooksDir = path.join(wolfDir, "hooks");
const sessionFile = path.join(hooksDir, "_session.json");
const raw = await readStdin();
let input;
try {
input = JSON.parse(raw);
}
catch {
process.exit(0);
return;
}
const filePath = input.tool_input?.file_path ?? input.tool_input?.path ?? "";
if (!filePath) {
process.exit(0);
return;
}
const normalizedFile = normalizePath(filePath);
// Skip tracking for .wolf/ internal files — they're infrastructure, not project files.
// Counting them inflates anatomy miss rates since .wolf/ is excluded from anatomy scanning.
const projectDir = normalizePath(process.env.CLAUDE_PROJECT_DIR || process.cwd());
const relToProject = normalizedFile.startsWith(projectDir)
? normalizedFile.slice(projectDir.length).replace(/^\//, "")
: "";
if (relToProject.startsWith(".wolf/") || relToProject.startsWith(".wolf\\")) {
process.exit(0);
return;
}
const session = readJSON(sessionFile, {
session_id: "", files_read: {}, anatomy_hits: 0, anatomy_misses: 0,
repeated_reads_warned: 0,
});
// Check if already read this session
if (session.files_read[normalizedFile]) {
const prev = session.files_read[normalizedFile];
process.stderr.write(`⚡ OpenWolf: ${path.basename(normalizedFile)} was already read this session (~${prev.tokens} tokens). Consider using your existing knowledge of this file.\n`);
session.files_read[normalizedFile].count++;
session.repeated_reads_warned++;
writeJSON(sessionFile, session);
process.exit(0);
return;
}
// Check anatomy.md for this file
const anatomyContent = readMarkdown(path.join(wolfDir, "anatomy.md"));
const sections = parseAnatomy(anatomyContent);
let found = false;
for (const [sectionKey, entries] of sections) {
for (const entry of entries) {
// Build the full relative path from the section key + filename for accurate matching
const entryRelPath = normalizePath(path.join(sectionKey, entry.file));
if (normalizedFile.endsWith(entryRelPath) || normalizedFile.endsWith("/" + entryRelPath)) {
process.stderr.write(`📋 OpenWolf anatomy: ${entry.file}${entry.description} (~${entry.tokens} tok)\n`);
found = true;
break;
}
}
if (found)
break;
}
if (found) {
session.anatomy_hits++;
}
else {
session.anatomy_misses++;
}
// Record initial read entry (tokens will be updated in post-read)
session.files_read[normalizedFile] = {
count: 1,
tokens: 0,
first_read: new Date().toISOString(),
};
writeJSON(sessionFile, session);
process.exit(0);
}
main().catch(() => process.exit(0));
//# sourceMappingURL=pre-read.js.map
+121
View File
@@ -0,0 +1,121 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { getWolfDir, ensureWolfDir, readJSON, readMarkdown, readStdin } from "./shared.js";
async function main() {
ensureWolfDir();
const wolfDir = getWolfDir();
const raw = await readStdin();
let input;
try {
input = JSON.parse(raw);
}
catch {
process.exit(0);
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()) {
process.exit(0);
return;
}
// 1. Cerebrum Do-Not-Repeat check
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)) {
checkBugLog(wolfDir, filePath, oldStr, newStr, content);
}
process.exit(0);
}
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("-"));
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)) {
process.stderr.write(`⚠️ OpenWolf cerebrum warning: "${trimmed}" — check your code before proceeding.\n`);
}
}
catch { }
}
}
}
// 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 = readJSON(bugLogPath, { version: 1, bugs: [] });
if (bugLog.bugs.length === 0)
return;
const basename = path.basename(filePath);
// ONLY surface bugs that match the SAME file being edited.
// Cross-file matching is too noisy and risks misdirecting Claude.
const fileMatches = bugLog.bugs.filter(b => {
const bugBasename = path.basename(b.file);
return bugBasename === basename;
});
if (fileMatches.length === 0)
return;
// Further filter: require tag or error_message overlap with the edit content
const editText = (oldStr + " " + newStr + " " + content).toLowerCase();
const editTokens = tokenize(editText);
const relevant = fileMatches.filter(bug => {
// Check if any bug tag appears in the edit content
const tagHit = bug.tags.some(t => editText.includes(t.toLowerCase()));
if (tagHit)
return true;
// Check meaningful word overlap (excluding stop words)
const bugTokens = tokenize(bug.error_message + " " + bug.root_cause);
const overlap = [...editTokens].filter(t => bugTokens.has(t));
// Require at least 3 meaningful overlapping words
return overlap.length >= 3;
});
if (relevant.length === 0)
return;
// Surface as a FYI, not a directive — Claude should evaluate, not blindly apply
process.stderr.write(`📋 OpenWolf buglog: ${relevant.length} past bug(s) found for ${basename} — review for context, do NOT apply blindly:\n`);
for (const bug of relevant.slice(0, 2)) {
process.stderr.write(` [${bug.id}] "${bug.error_message.slice(0, 70)}"\n Cause: ${bug.root_cause.slice(0, 80)}\n Fix: ${bug.fix.slice(0, 80)}\n`);
}
}
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()));
}
main().catch(() => process.exit(0));
//# sourceMappingURL=pre-write.js.map
+77
View File
@@ -0,0 +1,77 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { getWolfDir, ensureWolfDir, writeJSON, appendMarkdown, readJSON, timestamp, timeShort } from "./shared.js";
async function main() {
ensureWolfDir();
const wolfDir = getWolfDir();
// Clean up stale .tmp files left from failed atomic writes
try {
const files = fs.readdirSync(wolfDir);
for (const f of files) {
if (f.endsWith(".tmp")) {
try {
fs.unlinkSync(path.join(wolfDir, f));
}
catch { }
}
}
}
catch { }
const hooksDir = path.join(wolfDir, "hooks");
const sessionFile = path.join(hooksDir, "_session.json");
const now = new Date();
const sessionId = `session-${now.toISOString().slice(0, 10)}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`;
// Create fresh session state
writeJSON(sessionFile, {
session_id: sessionId,
started: timestamp(),
files_read: {},
files_written: [],
edit_counts: {},
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_warned: 0,
cerebrum_warnings: 0,
stop_count: 0,
});
// Append session header to memory.md
const memoryPath = path.join(wolfDir, "memory.md");
const header = `\n## Session: ${now.toISOString().slice(0, 10)} ${timeShort()}\n\n| Time | Action | File(s) | Outcome | ~Tokens |\n|------|--------|---------|---------|--------|\n`;
appendMarkdown(memoryPath, header);
// Check cerebrum freshness — remind Claude to learn
try {
const cerebrumPath = path.join(wolfDir, "cerebrum.md");
const cerebrumContent = fs.readFileSync(cerebrumPath, "utf-8");
const stat = fs.statSync(cerebrumPath);
const daysSinceUpdate = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60 * 24);
// Count actual entries (non-comment, non-empty lines in content sections)
const entryLines = cerebrumContent.split("\n").filter(l => {
const t = l.trim();
return t.startsWith("- ") || t.startsWith("* ") || (t.startsWith("[") && t.includes("]"));
});
if (entryLines.length < 3) {
process.stderr.write(`💡 OpenWolf: cerebrum.md has only ${entryLines.length} entries. Learn from this session — record user preferences, project conventions, and mistakes to .wolf/cerebrum.md.\n`);
}
else if (daysSinceUpdate > 3) {
process.stderr.write(`💡 OpenWolf: cerebrum.md hasn't been updated in ${Math.floor(daysSinceUpdate)} days. Look for opportunities to add learnings this session.\n`);
}
}
catch { }
// Check buglog — remind if empty
try {
const buglogPath = path.join(wolfDir, "buglog.json");
const buglog = readJSON(buglogPath, { bugs: [] });
if (buglog.bugs.length === 0) {
process.stderr.write(`📋 OpenWolf: buglog.json is empty. If you encounter or fix any bugs, errors, or failed tests this session, log them to .wolf/buglog.json.\n`);
}
}
catch { }
// Increment total_sessions in token-ledger
const ledgerPath = path.join(wolfDir, "token-ledger.json");
const ledger = readJSON(ledgerPath, { version: 1, lifetime: { total_sessions: 0 } });
ledger.lifetime.total_sessions++;
writeJSON(ledgerPath, ledger);
process.exit(0);
}
main().catch(() => process.exit(0));
//# sourceMappingURL=session-start.js.map
+614
View File
@@ -0,0 +1,614 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as crypto from "node:crypto";
export function getWolfDir() {
// Prefer CLAUDE_PROJECT_DIR so hooks work even if CWD changes during a session
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
return path.join(projectDir, ".wolf");
}
/**
* Bail out silently if .wolf/ directory doesn't exist in the current project.
* Call this at the top of every hook to avoid crashes in non-OpenWolf projects.
*/
export function ensureWolfDir() {
const wolfDir = getWolfDir();
if (!fs.existsSync(wolfDir)) {
process.exit(0);
}
}
export function readJSON(filePath, fallback) {
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
catch {
return fallback;
}
}
export function writeJSON(filePath, data) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir))
fs.mkdirSync(dir, { recursive: true });
const tmp = filePath + "." + crypto.randomBytes(4).toString("hex") + ".tmp";
try {
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
fs.renameSync(tmp, filePath);
}
catch {
// On Windows, rename can fail if another process holds a handle.
// Fall back to direct write and clean up the tmp file.
try {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
}
catch { }
try {
fs.unlinkSync(tmp);
}
catch { }
}
}
export function readMarkdown(filePath) {
try {
return fs.readFileSync(filePath, "utf-8");
}
catch {
return "";
}
}
export function appendMarkdown(filePath, line) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir))
fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(filePath, line, "utf-8");
}
export function parseAnatomy(content) {
const sections = new Map();
let currentSection = "";
for (const line of content.split("\n")) {
const sm = line.match(/^## (.+)/);
if (sm) {
currentSection = sm[1].trim();
if (!sections.has(currentSection))
sections.set(currentSection, []);
continue;
}
if (!currentSection)
continue;
const em = line.match(/^- `([^`]+)`(?:\s+—\s+(.+?))?\s*\(~(\d+)\s+tok\)$/);
if (em) {
sections.get(currentSection).push({
file: em[1],
description: em[2] || "",
tokens: parseInt(em[3], 10),
});
}
}
return sections;
}
export function serializeAnatomy(sections, metadata) {
const lines = [
"# anatomy.md",
"",
`> Auto-maintained by OpenWolf. Last scanned: ${metadata.lastScanned}`,
`> Files: ${metadata.fileCount} tracked | Anatomy hits: ${metadata.hits} | Misses: ${metadata.misses}`,
"",
];
const keys = [...sections.keys()].sort();
for (const key of keys) {
lines.push(`## ${key}`);
lines.push("");
const entries = sections.get(key).sort((a, b) => a.file.localeCompare(b.file));
for (const e of entries) {
const desc = e.description ? `${e.description}` : "";
lines.push(`- \`${e.file}\`${desc} (~${e.tokens} tok)`);
}
lines.push("");
}
return lines.join("\n");
}
export function extractDescription(filePath) {
const MAX_DESC = 150;
const basename = path.basename(filePath);
const ext = path.extname(basename).toLowerCase();
const known = {
"package.json": "Node.js package manifest",
"tsconfig.json": "TypeScript configuration",
".gitignore": "Git ignore rules",
"README.md": "Project documentation",
"composer.json": "PHP package manifest",
"requirements.txt": "Python dependencies",
"schema.sql": "Database schema",
"Dockerfile": "Docker container definition",
"docker-compose.yml": "Docker Compose services",
"Cargo.toml": "Rust package manifest",
"go.mod": "Go module definition",
"Gemfile": "Ruby dependencies",
"pubspec.yaml": "Dart/Flutter package manifest",
};
if (known[basename])
return known[basename];
let content;
try {
const fd = fs.openSync(filePath, "r");
const buf = Buffer.alloc(12288); // 12KB
const n = fs.readSync(fd, buf, 0, 12288, 0);
fs.closeSync(fd);
content = buf.subarray(0, n).toString("utf-8");
}
catch {
return "";
}
if (!content.trim())
return "";
const cap = (s) => s.length <= MAX_DESC ? s : s.slice(0, MAX_DESC - 3) + "...";
// Markdown heading
if (ext === ".md" || ext === ".mdx") {
const m = content.match(/^#{1,2}\s+(.+)$/m);
if (m)
return cap(m[1].trim());
}
// HTML title
if (ext === ".html" || ext === ".htm") {
const m = content.match(/<title[^>]*>([^<]+)<\/title>/i);
if (m)
return cap(m[1].trim());
}
// JSDoc / PHPDoc / Javadoc — first meaningful line
const jm = content.match(/\/\*\*\s*\n?\s*\*?\s*(.+)/);
if (jm) {
const l = jm[1].replace(/\*\/$/, "").trim();
if (l && !l.startsWith("@") && l.length > 5)
return cap(l);
}
// Python docstring
if (ext === ".py") {
const dm = content.match(/^(?:#[^\n]*\n)*\s*(?:"""(.+?)"""|'''(.+?)''')/s);
if (dm) {
const first = (dm[1] || dm[2]).split("\n")[0].trim();
if (first && first.length > 3)
return cap(first);
}
}
// Rust doc comments
if (ext === ".rs") {
const lines = content.split("\n");
for (const line of lines.slice(0, 20)) {
const m = line.match(/^\s*(?:\/\/\/|\/\/!)\s*(.+)/);
if (m && m[1].length > 5)
return cap(m[1].trim());
}
}
// Go package comment
if (ext === ".go") {
const m = content.match(/\/\/\s*Package\s+\w+\s+(.*)/);
if (m)
return cap(m[1].trim());
}
// C# XML doc
if (ext === ".cs") {
const m = content.match(/<summary>\s*([\s\S]*?)\s*<\/summary>/);
if (m) {
const text = m[1].replace(/\/\/\/\s*/g, "").replace(/\s+/g, " ").trim();
if (text.length > 5)
return cap(text);
}
}
// Elixir @moduledoc
if (ext === ".ex" || ext === ".exs") {
const m = content.match(/@moduledoc\s+"""\s*\n\s*(.*)/);
if (m)
return cap(m[1].trim());
}
// Header comment (skip generic ones)
const hdrLines = content.split("\n");
for (const line of hdrLines.slice(0, 15)) {
const t = line.trim();
if (!t || t === "<?php" || t.startsWith("#!") || t.startsWith("namespace") || t.startsWith("use ") || t.startsWith("import ") || t.startsWith("from ") || t.startsWith("require") || t.startsWith("module "))
continue;
const cm = t.match(/^(?:\/\/|#|--)\s*(.+)/);
if (cm) {
const text = cm[1].trim();
const lower = text.toLowerCase();
if (text.length > 5 && !lower.startsWith("copyright") && !lower.startsWith("license") && !lower.startsWith("@") && !lower.startsWith("strict") && !lower.startsWith("generated") && !lower.startsWith("eslint-") && !lower.startsWith("nolint")) {
return cap(text);
}
}
if (!t.startsWith("//") && !t.startsWith("#") && !t.startsWith("/*") && !t.startsWith("*") && !t.startsWith("--"))
break;
}
// ─── PHP / Laravel ───────────────────────────────────────
if (ext === ".php") {
if (basename.endsWith(".blade.php")) {
const ext2 = content.match(/@extends\(\s*['"]([^'"]+)['"]\s*\)/);
const sections = (content.match(/@section\(\s*['"](\w+)['"]/g) || []).map(s => s.match(/['"](\w+)['"]/)?.[1]).filter(Boolean);
const parts = [];
if (ext2)
parts.push(`extends ${ext2[1]}`);
if (sections.length)
parts.push(`sections: ${sections.join(", ")}`);
return cap(parts.length ? `Blade: ${parts.join(", ")}` : "Blade template");
}
const classM = content.match(/class\s+(\w+)(?:\s+extends\s+(\w+))?/);
const className = classM?.[1] || "";
const parent = classM?.[2] || "";
const pubMethods = (content.match(/public\s+function\s+(\w+)/g) || [])
.map(m => m.match(/public\s+function\s+(\w+)/)?.[1])
.filter(n => n && n !== "__construct" && n !== "middleware");
if (basename.endsWith("Controller.php") || parent === "Controller") {
if (pubMethods.length > 0) {
const display = pubMethods.slice(0, 5).join(", ");
return cap(pubMethods.length > 5 ? `${display} + ${pubMethods.length - 5} more` : display);
}
}
if (parent === "Model" || parent === "Authenticatable") {
const parts = [];
const tbl = content.match(/\$table\s*=\s*['"]([^'"]+)['"]/);
if (tbl)
parts.push(`table: ${tbl[1]}`);
const fill = content.match(/\$fillable\s*=\s*\[([^\]]*)\]/s);
if (fill) {
const c = (fill[1].match(/['"]/g) || []).length / 2;
parts.push(`${Math.floor(c)} fields`);
}
const rels = (content.match(/\$this->(hasMany|hasOne|belongsTo|belongsToMany|morphMany|morphTo)\(/g) || []).length;
if (rels)
parts.push(`${rels} rels`);
return cap(parts.length ? `Model — ${parts.join(", ")}` : `Model: ${className}`);
}
if (basename.match(/^\d{4}_\d{2}_\d{2}/)) {
const create = content.match(/Schema::create\(\s*['"]([^'"]+)['"]/);
if (create)
return `Migration: create ${create[1]} table`;
const alter = content.match(/Schema::table\(\s*['"]([^'"]+)['"]/);
if (alter)
return `Migration: alter ${alter[1]} table`;
return "Database migration";
}
if (className && pubMethods.length > 0) {
const display = pubMethods.slice(0, 4).join(", ");
return cap(pubMethods.length > 4 ? `${className}: ${display} + ${pubMethods.length - 4} more` : `${className}: ${display}`);
}
}
// ─── TS/JS/React/Next.js ─────────────────────────────────
if (ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx" || ext === ".mjs" || ext === ".cjs") {
// React component
if (ext === ".tsx" || ext === ".jsx") {
const comp = content.match(/(?:export\s+(?:default\s+)?)?(?:function|const)\s+(\w+)/);
const parts = [];
if (comp)
parts.push(comp[1]);
const renders = [];
if (/<(?:form|Form)/i.test(content))
renders.push("form");
if (/<(?:table|Table|DataTable)/i.test(content))
renders.push("table");
if (/<(?:dialog|Dialog|Modal|Drawer)/i.test(content))
renders.push("modal");
if (renders.length)
parts.push(`renders ${renders.join(", ")}`);
if (parts.length)
return cap(parts.join(" — "));
}
// Next.js conventions
if (basename === "page.tsx" || basename === "page.js")
return "Next.js page component";
if (basename === "layout.tsx" || basename === "layout.js")
return "Next.js layout";
if (basename === "route.ts" || basename === "route.js") {
const methods = [...new Set((content.match(/export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)/g) || [])
.map(m => m.match(/(GET|POST|PUT|PATCH|DELETE)/)?.[1]))].filter(Boolean);
return methods.length ? `Next.js API route: ${methods.join(", ")}` : "Next.js API route";
}
// Express/Fastify routes
const routeHits = content.match(/\.(get|post|put|patch|delete)\s*\(\s*['"`]/g);
if (routeHits && routeHits.length > 0) {
const methods = [...new Set(routeHits.map(r => r.match(/\.(get|post|put|patch|delete)/)?.[1]?.toUpperCase()))];
return cap(`API routes: ${methods.join(", ")} (${routeHits.length} endpoints)`);
}
// tRPC router
if (content.includes("createTRPCRouter") || content.includes("publicProcedure")) {
const procs = (content.match(/\.(query|mutation|subscription)\s*\(/g) || []).length;
return procs ? `tRPC router: ${procs} procedures` : "tRPC router";
}
// Zod schemas
if (content.includes("z.object") || content.includes("z.string")) {
const schemas = (content.match(/(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*z\./g) || [])
.map(s => s.match(/(?:const|let)\s+(\w+)/)?.[1]).filter(Boolean);
if (schemas.length)
return cap(`Zod schemas: ${schemas.slice(0, 4).join(", ")}${schemas.length > 4 ? ` + ${schemas.length - 4} more` : ""}`);
}
// Exports summary
const exports = (content.match(/export\s+(?:async\s+)?(?:function|class|const|interface|type|enum)\s+(\w+)/g) || [])
.map(e => e.match(/(\w+)$/)?.[1]).filter(Boolean);
if (exports.length > 0 && exports.length <= 5)
return `Exports ${exports.join(", ")}`;
if (exports.length > 5)
return cap(`Exports ${exports.slice(0, 4).join(", ")} + ${exports.length - 4} more`);
}
// ─── Python / Django / FastAPI / Flask ────────────────────
if (ext === ".py") {
// Django model
if (content.includes("models.Model")) {
const cls = content.match(/class\s+(\w+)\(.*models\.Model\)/);
const fields = (content.match(/^\s+\w+\s*=\s*models\.\w+/gm) || []).length;
return cap(`Model: ${cls?.[1] || "unknown"}, ${fields} fields`);
}
// FastAPI/Flask routes
if (content.includes("@router.") || content.includes("@app.")) {
const routes = (content.match(/@(?:router|app)\.(get|post|put|patch|delete)\s*\(/g) || []);
return cap(routes.length ? `API: ${routes.length} endpoints` : "API router");
}
// Pydantic
if (content.includes("BaseModel") && content.includes("Field(")) {
const cls = content.match(/class\s+(\w+)\(.*BaseModel\)/);
return cls ? `Pydantic: ${cls[1]}` : "Pydantic model";
}
// Celery
if (content.includes("@shared_task") || content.includes("@app.task")) {
const tasks = (content.match(/def\s+(\w+)/g) || []).map(m => m.match(/def\s+(\w+)/)?.[1]).filter(n => n && !n.startsWith("_"));
return cap(tasks.length ? `Celery tasks: ${tasks.join(", ")}` : "Celery task");
}
// Generic
const pyClass = content.match(/class\s+(\w+)/);
const funcs = (content.match(/def\s+(\w+)/g) || []).map(f => f.match(/def\s+(\w+)/)?.[1]).filter(n => n && !n.startsWith("_"));
if (pyClass && funcs.length > 0)
return cap(funcs.length > 4 ? `${pyClass[1]}: ${funcs.slice(0, 4).join(", ")} + ${funcs.length - 4} more` : `${pyClass[1]}: ${funcs.join(", ")}`);
if (funcs.length > 0)
return cap(funcs.slice(0, 4).join(", "));
}
// ─── Go ──────────────────────────────────────────────────
if (ext === ".go") {
const handlers = (content.match(/func\s+(\w+)\s*\(\s*\w+\s+http\.ResponseWriter/g) || [])
.map(m => m.match(/func\s+(\w+)/)?.[1]).filter(Boolean);
if (handlers.length)
return cap(`HTTP handlers: ${handlers.slice(0, 5).join(", ")}`);
const iface = content.match(/type\s+(\w+)\s+interface\s*\{/);
if (iface)
return `Interface: ${iface[1]}`;
const structM = content.match(/type\s+(\w+)\s+struct\s*\{/);
if (structM)
return `Struct: ${structM[1]}`;
const funcs = (content.match(/^func\s+(\w+)/gm) || []).map(m => m.match(/func\s+(\w+)/)?.[1]).filter(n => n && n[0] === n[0].toUpperCase());
if (funcs.length)
return cap(funcs.slice(0, 5).join(", "));
}
// ─── Rust ────────────────────────────────────────────────
if (ext === ".rs") {
const structM = content.match(/pub\s+struct\s+(\w+)/);
if (structM) {
const methods = (content.match(/pub\s+(?:async\s+)?fn\s+(\w+)/g) || []).map(m => m.match(/fn\s+(\w+)/)?.[1]).filter(Boolean);
return cap(methods.length ? `${structM[1]}: ${methods.slice(0, 4).join(", ")}` : `Struct: ${structM[1]}`);
}
const traitM = content.match(/pub\s+trait\s+(\w+)/);
if (traitM)
return `Trait: ${traitM[1]}`;
const enumM = content.match(/pub\s+enum\s+(\w+)/);
if (enumM)
return `Enum: ${enumM[1]}`;
const fns = (content.match(/pub\s+(?:async\s+)?fn\s+(\w+)/g) || []).map(m => m.match(/fn\s+(\w+)/)?.[1]).filter(Boolean);
if (fns.length)
return cap(fns.slice(0, 5).join(", "));
}
// ─── Java / Spring ───────────────────────────────────────
if (ext === ".java") {
const cls = content.match(/(?:public\s+)?class\s+(\w+)/);
const className = cls?.[1] || basename.replace(".java", "");
const annotations = (content.match(/@(RestController|Controller|Service|Repository|Component|Entity|Configuration)/g) || []).map(a => a.slice(1));
const mappings = (content.match(/@(?:Get|Post|Put|Patch|Delete|Request)Mapping/g) || []).length;
if (mappings)
return cap(`${annotations[0] || "Spring"}: ${className} (${mappings} endpoints)`);
if (annotations.length)
return `${annotations[0]}: ${className}`;
if (content.includes("@Entity"))
return `Entity: ${className}`;
const methods = (content.match(/public\s+(?:static\s+)?(?:\w+(?:<[\w,\s]+>)?)\s+(\w+)\s*\(/g) || [])
.map(m => m.match(/(\w+)\s*\(/)?.[1]).filter(n => n && n !== className);
if (methods.length)
return cap(`${className}: ${methods.slice(0, 4).join(", ")}`);
return className ? `Class: ${className}` : "";
}
// ─── Kotlin ──────────────────────────────────────────────
if (ext === ".kt" || ext === ".kts") {
const cls = content.match(/(?:data\s+)?class\s+(\w+)/);
if (content.match(/data\s+class/))
return `Data class: ${cls?.[1] || basename.replace(/\.kts?$/, "")}`;
if (content.includes("routing {"))
return "Ktor routing";
const fns = (content.match(/fun\s+(\w+)/g) || []).map(m => m.match(/fun\s+(\w+)/)?.[1]).filter(Boolean);
if (cls && fns.length)
return cap(`${cls[1]}: ${fns.slice(0, 4).join(", ")}`);
if (fns.length)
return cap(fns.slice(0, 5).join(", "));
}
// ─── C# / .NET ───────────────────────────────────────────
if (ext === ".cs") {
const cls = content.match(/(?:public\s+)?(?:partial\s+)?class\s+(\w+)(?:\s*:\s*(\w+))?/);
const className = cls?.[1] || basename.replace(".cs", "");
const parent = cls?.[2] || "";
if (parent === "Controller" || parent === "ControllerBase" || content.includes("[ApiController]")) {
const actions = (content.match(/\[Http(Get|Post|Put|Patch|Delete)\]/g) || []).map(a => a.match(/Http(\w+)/)?.[1]).filter(Boolean);
return cap(actions.length ? `API Controller: ${className} (${[...new Set(actions)].join(", ")})` : `Controller: ${className}`);
}
if (parent === "DbContext" || content.includes("DbSet<")) {
const sets = (content.match(/DbSet<(\w+)>/g) || []).map(s => s.match(/<(\w+)>/)?.[1]).filter(Boolean);
return cap(sets.length ? `DbContext: ${sets.join(", ")}` : `DbContext: ${className}`);
}
return className ? `Class: ${className}` : "";
}
// ─── Ruby / Rails ────────────────────────────────────────
if (ext === ".rb") {
const cls = content.match(/class\s+(\w+)(?:\s*<\s*(\w+(?:::\w+)?))?/);
const className = cls?.[1] || "";
const parent = cls?.[2] || "";
if (parent?.includes("Controller")) {
const actions = (content.match(/def\s+(index|show|new|create|edit|update|destroy|\w+)/g) || [])
.map(m => m.match(/def\s+(\w+)/)?.[1]).filter(n => n && !n.startsWith("_"));
return cap(actions.length ? `Controller: ${actions.join(", ")}` : `Controller: ${className}`);
}
if (parent === "ApplicationRecord" || parent === "ActiveRecord::Base")
return `Model: ${className}`;
if (basename.match(/^\d{14}_/)) {
const create = content.match(/create_table\s+:(\w+)/);
return create ? `Migration: create ${create[1]}` : "Database migration";
}
const methods = (content.match(/def\s+(\w+)/g) || []).map(m => m.match(/def\s+(\w+)/)?.[1]).filter(n => n && !n.startsWith("_"));
if (cls && methods.length)
return cap(`${className}: ${methods.slice(0, 4).join(", ")}`);
}
// ─── Swift ───────────────────────────────────────────────
if (ext === ".swift") {
if (content.includes(": View") || content.includes("some View")) {
const name = content.match(/struct\s+(\w+)\s*:\s*View/);
return name ? `SwiftUI view: ${name[1]}` : "SwiftUI view";
}
const proto = content.match(/protocol\s+(\w+)/);
if (proto)
return `Protocol: ${proto[1]}`;
const struct = content.match(/(?:public\s+)?struct\s+(\w+)/);
const cls = content.match(/(?:public\s+)?class\s+(\w+)/);
const name = struct?.[1] || cls?.[1] || "";
if (name)
return `${struct ? "Struct" : "Class"}: ${name}`;
}
// ─── Dart / Flutter ──────────────────────────────────────
if (ext === ".dart") {
if (content.includes("StatefulWidget") || content.includes("StatelessWidget")) {
const name = content.match(/class\s+(\w+)\s+extends\s+(?:Stateful|Stateless)Widget/);
return name ? `${content.includes("StatefulWidget") ? "Stateful" : "Stateless"} widget: ${name[1]}` : "Flutter widget";
}
const cls = content.match(/class\s+(\w+)/);
if (cls)
return `Class: ${cls[1]}`;
}
// ─── Vue / Svelte / Astro ────────────────────────────────
if (ext === ".vue") {
const name = content.match(/name:\s*['"]([^'"]+)['"]/);
const setup = content.includes("<script setup");
const parts = [];
if (name)
parts.push(name[1]);
if (setup)
parts.push("setup");
return cap(parts.length ? `Vue: ${parts.join(", ")}` : "Vue component");
}
if (ext === ".svelte")
return `Svelte: ${basename.replace(".svelte", "")}`;
if (ext === ".astro")
return `Astro: ${basename.replace(".astro", "")}`;
// ─── CSS / SCSS / Less ───────────────────────────────────
if (ext === ".css" || ext === ".scss" || ext === ".less") {
const rules = (content.match(/^[.#@][^\n{]+/gm) || []).length;
const vars = (content.match(/--[\w-]+\s*:/g) || []).length;
const parts = [];
if (rules)
parts.push(`${rules} rules`);
if (vars)
parts.push(`${vars} vars`);
return cap(parts.length ? `Styles: ${parts.join(", ")}` : "Stylesheet");
}
// ─── SQL ─────────────────────────────────────────────────
if (ext === ".sql") {
const creates = (content.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"']?(\w+)/gi) || [])
.map(m => m.match(/(?:TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?)([`"']?\w+)/i)?.[1]?.replace(/[`"']/g, "")).filter(Boolean);
if (creates.length)
return cap(`SQL: tables: ${creates.slice(0, 4).join(", ")}`);
}
// ─── Proto / GraphQL ─────────────────────────────────────
if (ext === ".proto") {
const msgs = (content.match(/message\s+(\w+)/g) || []).map(m => m.match(/message\s+(\w+)/)?.[1]).filter(Boolean);
const services = (content.match(/service\s+(\w+)/g) || []).map(m => m.match(/service\s+(\w+)/)?.[1]).filter(Boolean);
const parts = [];
if (msgs.length)
parts.push(`messages: ${msgs.slice(0, 3).join(", ")}`);
if (services.length)
parts.push(`services: ${services.join(", ")}`);
return cap(parts.length ? `Proto: ${parts.join(", ")}` : "");
}
if (ext === ".graphql" || ext === ".gql") {
const types = (content.match(/type\s+(\w+)/g) || []).map(m => m.match(/type\s+(\w+)/)?.[1]).filter(Boolean);
return cap(types.length ? `GraphQL: types: ${types.slice(0, 4).join(", ")}` : "GraphQL schema");
}
// ─── YAML ────────────────────────────────────────────────
if (ext === ".yaml" || ext === ".yml") {
if (content.includes("runs-on:")) {
const name = content.match(/^name:\s*(.+)$/m);
return cap(name ? `CI: ${name[1].trim()}` : "GitHub Actions workflow");
}
if (content.includes("apiVersion:") && content.includes("kind:")) {
const kind = content.match(/kind:\s*(\w+)/);
return cap(kind ? `K8s ${kind[1]}` : "Kubernetes manifest");
}
if (content.includes("services:") && (basename.includes("docker") || basename.includes("compose"))) {
const services = (content.match(/^\s{2}\w+:/gm) || []).length;
return `Docker Compose: ${services} services`;
}
}
// ─── TOML ────────────────────────────────────────────────
if (ext === ".toml") {
const desc = content.match(/^description\s*=\s*"([^"]+)"/m);
if (desc)
return cap(desc[1]);
}
// ─── Elixir ──────────────────────────────────────────────
if (ext === ".ex" || ext === ".exs") {
const mod = content.match(/defmodule\s+([\w.]+)/);
if (content.includes("Phoenix.LiveView"))
return cap(mod ? `LiveView: ${mod[1]}` : "Phoenix LiveView");
if (content.includes("Controller"))
return cap(mod ? `Phoenix controller: ${mod[1]}` : "Phoenix controller");
const fns = (content.match(/def\s+(\w+)/g) || []).map(m => m.match(/def\s+(\w+)/)?.[1]).filter(Boolean);
if (mod && fns.length)
return cap(`${mod[1]}: ${fns.slice(0, 4).join(", ")}`);
if (mod)
return mod[1];
}
// ─── Lua ─────────────────────────────────────────────────
if (ext === ".lua") {
const fns = (content.match(/function\s+(?:\w+[.:])?(\w+)/g) || []).map(m => m.match(/(\w+)\s*$/)?.[1]).filter(Boolean);
if (fns.length)
return cap(fns.slice(0, 5).join(", "));
}
// ─── Zig ─────────────────────────────────────────────────
if (ext === ".zig") {
const fns = (content.match(/pub\s+fn\s+(\w+)/g) || []).map(m => m.match(/fn\s+(\w+)/)?.[1]).filter(Boolean);
if (fns.length)
return cap(fns.slice(0, 5).join(", "));
}
// Last resort
const declM = content.match(/(?:function|class|const|interface|type|enum)\s+(\w+)/);
if (declM) {
const name = declM[1];
const methods = (content.match(/(?:public\s+)?(?:async\s+)?(?:function\s+|(?:get|set)\s+)(\w+)\s*\(/g) || [])
.map(m => m.match(/(\w+)\s*\(/)?.[1]).filter(n => n && n !== name && n !== "__construct" && n !== "constructor");
if (methods.length > 0 && methods.length <= 5)
return cap(`${name}: ${methods.join(", ")}`);
if (methods.length > 5)
return cap(`${name}: ${methods.slice(0, 3).join(", ")} + ${methods.length - 3} more`);
return `Declares ${name}`;
}
return "";
}
export function estimateTokens(text, type = "mixed") {
const ratio = type === "code" ? 3.5 : type === "prose" ? 4.0 : 3.75;
return Math.ceil(text.length / ratio);
}
export function timestamp() {
return new Date().toISOString();
}
export function timeShort() {
const d = new Date();
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
export function readStdin() {
return new Promise((resolve) => {
const chunks = [];
process.stdin.on("data", (chunk) => chunks.push(chunk));
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
// If no stdin data after 4s, resolve with whatever we have so far.
// On Windows, stdin delivery from Claude Code hooks can be slow.
setTimeout(() => resolve(chunks.length ? Buffer.concat(chunks).toString("utf-8") : "{}"), 4000);
});
}
export function normalizePath(p) {
return p.replace(/\\/g, "/");
}
//# sourceMappingURL=shared.js.map
+147
View File
@@ -0,0 +1,147 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, appendMarkdown, timeShort } from "./shared.js";
async function main() {
ensureWolfDir();
const wolfDir = getWolfDir();
const hooksDir = path.join(wolfDir, "hooks");
const sessionFile = path.join(hooksDir, "_session.json");
const session = readJSON(sessionFile, {
session_id: "",
started: "",
files_read: {},
files_written: [],
edit_counts: {},
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_warned: 0,
cerebrum_warnings: 0,
stop_count: 0,
});
session.stop_count++;
// Only write to ledger if there's been activity
const readCount = Object.keys(session.files_read).length;
const writeCount = session.files_written.length;
if (readCount === 0 && writeCount === 0) {
writeJSON(sessionFile, session);
process.exit(0);
return;
}
// Check for files edited many times without a buglog entry
checkForMissingBugLogs(wolfDir, session);
// Check if cerebrum was updated this session (it should be if there were edits)
checkCerebrumFreshness(wolfDir, session);
// Build session entry for ledger
const reads = Object.entries(session.files_read).map(([file, data]) => ({
file,
tokens_estimated: data.tokens,
was_repeated: data.count > 1,
anatomy_had_description: false, // simplified
}));
const writes = session.files_written.map((w) => ({
file: w.file,
tokens_estimated: w.tokens,
action: w.action,
}));
const inputTokens = reads.reduce((sum, r) => sum + r.tokens_estimated, 0);
const outputTokens = writes.reduce((sum, w) => sum + w.tokens_estimated, 0);
const sessionEntry = {
id: session.session_id,
started: session.started,
ended: new Date().toISOString(),
reads,
writes,
totals: {
input_tokens_estimated: inputTokens,
output_tokens_estimated: outputTokens,
reads_count: readCount,
writes_count: writeCount,
repeated_reads_blocked: session.repeated_reads_warned,
anatomy_lookups: session.anatomy_hits,
},
};
// Update token-ledger.json
const ledgerPath = path.join(wolfDir, "token-ledger.json");
const ledger = readJSON(ledgerPath, {
version: 1,
created_at: "",
lifetime: {
total_tokens_estimated: 0,
total_reads: 0,
total_writes: 0,
total_sessions: 0,
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_blocked: 0,
estimated_savings_vs_bare_cli: 0,
},
sessions: [],
daemon_usage: [],
waste_flags: [],
optimization_report: { last_generated: null, patterns: [] },
});
ledger.sessions.push(sessionEntry);
ledger.lifetime.total_reads += readCount;
ledger.lifetime.total_writes += writeCount;
ledger.lifetime.total_tokens_estimated += inputTokens + outputTokens;
ledger.lifetime.anatomy_hits += session.anatomy_hits;
ledger.lifetime.anatomy_misses += session.anatomy_misses;
ledger.lifetime.repeated_reads_blocked += session.repeated_reads_warned;
// Estimate savings: anatomy hits save ~200 tokens each, repeated reads blocked save their token count
const savedFromAnatomy = session.anatomy_hits * 200;
const savedFromRepeats = Object.values(session.files_read)
.filter((r) => r.count > 1)
.reduce((sum, r) => sum + r.tokens * (r.count - 1), 0);
ledger.lifetime.estimated_savings_vs_bare_cli += savedFromAnatomy + savedFromRepeats;
writeJSON(ledgerPath, ledger);
// Write a session summary line to memory.md if there was meaningful activity
if (writeCount > 0) {
try {
const uniqueFiles = new Set(session.files_written.map(w => path.basename(w.file)));
const fileList = [...uniqueFiles].slice(0, 5).join(", ");
const memoryPath = path.join(wolfDir, "memory.md");
appendMarkdown(memoryPath, `| ${timeShort()} | Session end: ${writeCount} writes across ${uniqueFiles.size} files (${fileList}) | ${readCount} reads | ~${inputTokens + outputTokens} tok |\n`);
}
catch { }
}
writeJSON(sessionFile, session);
process.exit(0);
}
/**
* Check if files were edited multiple times but buglog.json wasn't updated.
* Emit a stderr reminder so Claude sees it in the next turn.
*/
function checkForMissingBugLogs(wolfDir, session) {
if (!session.edit_counts)
return;
const multiEditFiles = Object.entries(session.edit_counts)
.filter(([, count]) => count >= 3)
.map(([file]) => path.basename(file));
if (multiEditFiles.length === 0)
return;
// Check if buglog was written to this session
const buglogWritten = session.files_written.some(w => w.file.includes("buglog.json"));
if (!buglogWritten) {
process.stderr.write(`⚠️ OpenWolf: Files edited 3+ times this session (${multiEditFiles.join(", ")}) but buglog.json was not updated. If you fixed bugs, please log them.\n`);
}
}
/**
* Check if cerebrum.md was updated recently. If it hasn't been updated in
* a while and there was significant activity, emit a gentle reminder.
*/
function checkCerebrumFreshness(wolfDir, session) {
const cerebrumPath = path.join(wolfDir, "cerebrum.md");
try {
const stat = fs.statSync(cerebrumPath);
const hoursSinceUpdate = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60);
// If cerebrum hasn't been updated in 24h+ and there were significant writes
if (hoursSinceUpdate > 24 && session.files_written.length >= 3) {
process.stderr.write(`💡 OpenWolf: cerebrum.md hasn't been updated in ${Math.floor(hoursSinceUpdate)}h. Did you learn any user preferences, conventions, or gotchas this session? Consider updating .wolf/cerebrum.md.\n`);
}
}
catch {
// cerebrum.md doesn't exist, that's ok
}
}
main().catch(() => process.exit(0));
//# sourceMappingURL=stop.js.map