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:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user