chore(wolf): checkpoint buglog.json (1062 bugs)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgYRU3zDTqNGV5Jk8aiKLg
This commit is contained in:
Rongsong Shen 2026-08-27 08:27:43 +00:00
parent 13246a89c8
commit d8bf8fb2e1
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
27 changed files with 7702 additions and 897158 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,33 +1,75 @@
import * as path from "node:path";
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, parseAnatomy, estimateTokens, readStdin, normalizePath } from "./shared.js";
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, estimateTokens, readStdin, normalizePath, getProjectDir, hookMain, getSessionFilePath } from "./shared.js";
import { lookupEntry } from "./anatomy-store.js";
/**
* The PostToolUse payload carries the tool result in `tool_response`, whose
* shape depends on the tool and harness version: a plain string, an array of
* content blocks, or a structured object ({content} or {file:{content}}).
* Older builds of this hook read a `tool_output` field that never existed in
* Claude Code's payload, so read-token tracking was always zero (issue: the
* ledger under-reported every session).
*/
function extractToolResponseText(resp) {
if (typeof resp === "string")
return resp;
if (Array.isArray(resp)) {
return resp
.map((block) => (block && typeof block === "object" && typeof block.text === "string" ? block.text : ""))
.join("");
}
if (resp && typeof resp === "object") {
const obj = resp;
if (typeof obj.content === "string")
return obj.content;
if (obj.file && typeof obj.file.content === "string")
return obj.file.content;
}
return "";
}
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 sessionFile = getSessionFilePath(input);
const filePath = input.tool_input?.file_path ?? input.tool_input?.path ?? "";
const content = input.tool_output?.content ?? "";
const content = extractToolResponseText(input.tool_response) || input.tool_output?.content || "";
if (!filePath) {
process.exit(0);
return;
}
// Ranged reads: pre-read already recorded the contact with ranged:true.
// Registering them as full reads here is what used to make a later
// legitimate full read look like a duplicate (~20x warning inflation).
if (input.tool_input?.offset !== undefined || input.tool_input?.limit !== undefined) {
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 projectDir = normalizePath(getProjectDir());
const relToProject = normalizedFile.startsWith(projectDir)
? normalizedFile.slice(projectDir.length).replace(/^\//, "")
: "";
if (relToProject.startsWith(".wolf/") || relToProject.startsWith(".wolf\\")) {
process.exit(0);
// 2.4: measure OpenWolf's own context cost instead of hiding it. Tagged
// separately from project reads so anatomy hit-rates stay meaningful.
try {
if (content) {
const session = readJSON(sessionFile, { files_read: {} });
const tok = estimateTokens(content, "prose");
session.wolf_internal_tokens = (session.wolf_internal_tokens ?? 0) + tok;
const perFile = (session.wolf_internal_reads ?? {});
perFile[relToProject] = (perFile[relToProject] ?? 0) + tok;
session.wolf_internal_reads = perFile;
writeJSON(sessionFile, session);
}
}
catch { }
return;
}
const ext = path.extname(filePath).toLowerCase();
@ -35,35 +77,26 @@ async function main() {
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
// Fallback: if tool_output had no content, use the 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 entry = lookupEntry(wolfDir, projectDir, normalizedFile);
if (entry)
tokens = entry.tokens;
}
const session = readJSON(sessionFile, { files_read: {} });
if (session.files_read[normalizedFile]) {
session.files_read[normalizedFile].tokens = tokens;
const existing = session.files_read[normalizedFile];
if (existing && existing.ranged !== true) {
existing.tokens = tokens;
}
else {
// Fresh full read (or an upgrade of a ranged-only contact to a full read).
session.files_read[normalizedFile] = {
count: 1,
tokens,
first_read: new Date().toISOString(),
first_read: existing?.first_read ?? new Date().toISOString(),
};
}
writeJSON(sessionFile, session);
process.exit(0);
}
main().catch(() => process.exit(0));
hookMain("post-read", main);
//# sourceMappingURL=post-read.js.map

View file

@ -1,58 +1,86 @@
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";
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readBugLogFile, extractDescription, estimateTokens, appendMarkdown, timeShort, readStdin, normalizePath, isSensitiveFile, getProjectDir, emitHookJSON, recordInjection, hookMain, getSessionFilePath } from "./shared.js";
import { loadStoreReconciled, saveStore, renderToFile, sha256 } from "./anatomy-store.js";
import { withAnatomyLock, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js";
import { extractSymbols, symbolsSupported, SYMBOL_MIN_TOKENS } from "./symbol-extractor.js";
// File types where a value/string change is normal content editing, not a bug
// fix — auto bug detection never runs on these (see autoDetectBugFix). Without
// this, a version bump in a README or a key change in a JSON/YAML config is
// logged as a "wrong-value" bug, since the detector matches quoted spans
// (including markdown backticks) regardless of file type.
const NON_CODE_EXTS = new Set([
".md", ".mdx", ".markdown", ".txt", ".rst", ".adoc",
".json", ".jsonc", ".yaml", ".yml", ".toml", ".ini", ".env",
".lock", ".csv", ".tsv",
]);
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 projectRoot = getProjectDir();
const raw = await readStdin();
let input;
try {
input = JSON.parse(raw);
}
catch {
process.exit(0);
return;
}
const sessionFile = getSessionFilePath(input);
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
// .wolf/ state files skip anatomy/memory bookkeeping (self-referential),
// but 2.4 adds the budget-enforcement loop the platform's native memory
// uses: measure after every write, warn factually when a state file
// outgrows its budget. Oversized state gets injected/read every session,
// so bloat here is a recurring tax nothing else pushes back on.
const relPath = normalizePath(path.relative(projectRoot, absolutePath));
if (relPath.startsWith(".wolf/")) {
process.exit(0);
try {
const budget = stateBudgetFor(wolfDir, relPath);
if (budget !== null) {
const content = fs.readFileSync(absolutePath, "utf-8");
const tokens = estimateTokens(content, "prose");
if (tokens > budget) {
const session = readJSON(sessionFile, { files_written: [], edit_counts: {} });
const warned = (session.budget_warned ?? {});
if (!warned[relPath]) {
warned[relPath] = true;
session.budget_warned = warned;
const warn = `OpenWolf: ${relPath} is now ~${tokens} tokens; its budget is ${budget}. It is read at session starts, so size is a recurring cost. Consolidate or move detail into topic files, keeping the most recent and most important entries.`;
recordInjection(session, "budget_warn", warn);
writeJSON(sessionFile, session);
emitHookJSON("PostToolUse", { additionalContext: warn });
}
}
}
}
catch { }
return;
}
// Never track .env files in anatomy — they contain secrets
// Never track files outside the project root (e.g. the Claude Code scratchpad under
// /private/tmp). path.relative() yields ../.. section keys that pollute anatomy.md and are
// wiped again by every full `openwolf scan`, so the index churns instead of converging.
if (relPath.startsWith("..") || path.isAbsolute(relPath)) {
return;
}
// Never track secret-bearing files in anatomy/memory (issue #54): .env is
// not the only file whose *description* would leak sensitive content.
const baseName = path.basename(absolutePath);
if (baseName === ".env" || baseName.startsWith(".env.")) {
process.exit(0);
if (isSensitiveFile(baseName)) {
return;
}
const oldStr = input.tool_input?.old_string ?? "";
const newStr = input.tool_input?.new_string ?? "";
// 1. Update anatomy.md
// 1. Update the anatomy store, then re-render anatomy.md from it.
// All of this happens under the anatomy lock; if the lock cannot be
// acquired within budget we skip — a later writer converges the state.
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");
@ -66,40 +94,35 @@ async function main() {
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";
let size;
let mtimeMs;
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 { }
const st = fs.statSync(absolutePath);
size = st.size;
mtimeMs = st.mtimeMs;
}
catch { }
// Symbols are recomputed on every write (never carried over — the
// content just changed, so old line ranges would misdirect slice reads).
const symbols = tokens >= SYMBOL_MIN_TOKENS && symbolsSupported(ext)
? extractSymbols(fileContent, ext)
: undefined;
withAnatomyLock(wolfDir, HOOK_LOCK_BUDGET_MS, () => {
const store = loadStoreReconciled(wolfDir, projectRoot);
store.files[relPathLocal] = {
description: desc,
tokens,
hash: sha256(fileContent).slice(0, 16),
size,
mtimeMs,
updatedAt: new Date().toISOString(),
source: "hook",
symbols: symbols && symbols.length > 0 ? symbols : undefined,
};
store.meta.lastScanned = new Date().toISOString();
renderToFile(wolfDir, store);
saveStore(wolfDir, store);
});
}
catch { }
// 2. Append richer entry to memory.md
@ -137,9 +160,23 @@ async function main() {
});
const editKey = normalizePath(path.relative(projectRoot, absolutePath));
session.edit_counts[editKey] = (session.edit_counts[editKey] || 0) + 1;
if (session.files_read && session.files_read[normalizedFile]) {
delete session.files_read[normalizedFile];
}
// Once per file per session: firing on the 3rd edit AND every edit after
// it would hit ~39% of all write operations (measured) — pure noise.
if (!session.edit_warned)
session.edit_warned = {};
const editWarn = session.edit_counts[editKey] >= 3 && !session.edit_warned[editKey]
? `OpenWolf: ${baseName} has been edited ${session.edit_counts[editKey]} times this session. If you're fixing a bug, log it to .wolf/buglog.json.`
: "";
if (editWarn)
session.edit_warned[editKey] = true;
if (editWarn)
recordInjection(session, "edit_warn", editWarn);
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`);
if (editWarn) {
emitHookJSON("PostToolUse", { additionalContext: editWarn });
}
}
catch { }
@ -150,7 +187,16 @@ async function main() {
}
}
catch { }
process.exit(0);
}
/** Token budget for a .wolf state file, or null when unbudgeted (2.4). */
function stateBudgetFor(wolfDir, relPath) {
const defaults = {
".wolf/cerebrum.md": 2000,
".wolf/STATUS.md": 1000,
};
const cfg = readJSON(path.join(wolfDir, "config.json"), {});
const merged = { ...defaults, ...(cfg.openwolf?.context?.state_budgets ?? {}) };
return merged[relPath] ?? null;
}
// ─── Edit Summarizer ─────────────────────────────────────────────
function summarizeEdit(oldStr, newStr, filename) {
@ -160,7 +206,7 @@ function summarizeEdit(oldStr, newStr, filename) {
const newCount = newLines.length;
const ext = path.extname(filename).toLowerCase();
// --- Structural fixes ---
if (newStr.includes("try") && newStr.includes("catch") && !oldStr.includes("catch")) {
if (/\btry\b/.test(newStr) && hasCatchConstruct(newStr, ext) && !hasCatchConstruct(oldStr, ext)) {
return "added error handling";
}
if (newStr.includes("?.") && !oldStr.includes("?."))
@ -241,14 +287,30 @@ function extractCalls(code) {
.filter(n => n.length > 2 && !["if", "for", "while", "switch", "catch", "function", "return", "new", "typeof", "instanceof", "const", "let", "var"].includes(n)))];
}
// ─── Auto Bug Detection ──────────────────────────────────────────
function bugAutoDetectEnabled(wolfDir) {
try {
const cfg = readJSON(path.join(wolfDir, "config.json"), {});
// Default on; only an explicit `false` disables auto bug detection.
return cfg.openwolf?.buglog?.auto_detect !== false;
}
catch {
return true;
}
}
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();
// Bug-fix detection is a code concept — never fire on prose/docs/data files.
if (NON_CODE_EXTS.has(ext))
return;
// Respect an explicit opt-out in .wolf/config.json (default: enabled).
if (!bugAutoDetectEnabled(wolfDir))
return;
const bugLogPath = path.join(wolfDir, "buglog.json");
const bugLog = readBugLogFile(wolfDir);
const relFile = normalizePath(path.relative(projectRoot, absolutePath));
// Detect what kind of fix this is
const detection = detectFixPattern(oldStr, newStr, ext);
const detection = detectFixPattern(oldStr, newStr, ext, basename);
if (!detection)
return;
// Check for recent duplicate (same file + same category within 5 min)
@ -287,15 +349,16 @@ function autoDetectBugFix(wolfDir, absolutePath, projectRoot, oldStr, newStr) {
});
writeJSON(bugLogPath, bugLog);
}
function detectFixPattern(oldStr, newStr, ext) {
function detectFixPattern(oldStr, newStr, ext, basename) {
const oldLines = oldStr.split("\n");
const newLines = newStr.split("\n");
const isTest = isTestFile(basename);
// --- Error handling added ---
if (newStr.includes("catch") && !oldStr.includes("catch")) {
const fn = newStr.match(/(?:function|def|async)\s+(\w+)/)?.[1] || "unknown";
if (!isTest && hasCatchConstruct(newStr, ext) && !hasCatchConstruct(oldStr, ext)) {
const fn = newStr.match(/(?:function|def|async)\s+(\w+)/)?.[1];
return {
category: "error-handling",
summary: `Missing error handling in ${path.basename(fn)}`,
summary: `Missing error handling in ${fn ? `${fn}()` : basename}`,
rootCause: "Code path had no error handling — exceptions would propagate uncaught",
fix: `Added try/catch block`,
context: extractChangedLines(oldStr, newStr),
@ -307,14 +370,15 @@ function detectFixPattern(oldStr, newStr, ext) {
(/!==?\s*(null|undefined)/.test(newStr) && !/!==?\s*(null|undefined)/.test(oldStr))) {
return {
category: "null-safety",
summary: `Null/undefined access in ${path.basename(path.basename(""))}`,
summary: `Null/undefined access in ${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 (!isTest &&
/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 {
@ -460,7 +524,7 @@ function detectFixPattern(oldStr, newStr, ext) {
if (removedLines.length >= 2) {
return {
category: "refactor",
summary: `Significant refactor of ${path.basename("")}`,
summary: `Significant refactor of ${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("; "),
@ -469,6 +533,15 @@ function detectFixPattern(oldStr, newStr, ext) {
}
return null;
}
function hasCatchConstruct(code, ext) {
if (ext === ".py")
return /\bexcept\b[^\n]*:/.test(code);
return /\bcatch\s*[({]/.test(code);
}
function isTestFile(basename) {
return /(\.test\.|\.spec\.|_test\.|_spec\.)/i.test(basename) ||
/(Test|Tests|IT|Spec)\.\w+$/.test(basename);
}
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);
@ -499,5 +572,5 @@ function extractCSSProps(code) {
}
return props;
}
main().catch(() => process.exit(0));
hookMain("post-write", main);
//# sourceMappingURL=post-write.js.map

View file

@ -1,65 +1,181 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, parseAnatomy, readStdin, normalizePath } from "./shared.js";
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readStdin, normalizePath, getProjectDir, emitHookJSON, recordInjection, hookMain, getSessionFilePath } from "./shared.js";
import { lookupEntry } from "./anatomy-store.js";
function duplicateMode(wolfDir) {
const cfg = readJSON(path.join(wolfDir, "config.json"), {});
const mode = cfg.openwolf?.reads?.duplicate_mode;
return mode === "deny" || mode === "off" ? mode : "warn";
}
function skeletonHintsEnabled(wolfDir) {
const cfg = readJSON(path.join(wolfDir, "config.json"), {});
return cfg.openwolf?.reads?.skeleton_hints !== false;
}
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 sessionFile = getSessionFilePath(input);
const filePath = input.tool_input?.file_path ?? input.tool_input?.path ?? "";
if (!filePath) {
process.exit(0);
return;
}
// Ranged reads (offset/limit) are exactly what the symbol hints steer the
// model toward — never warn about, deny, or record them as full reads (a
// ranged first contact must not make a later legitimate full read look like
// a duplicate).
const isRangedRead = input.tool_input?.offset !== undefined || input.tool_input?.limit !== undefined;
// Subagents share this session file but not the main thread's context: a
// file the main thread read is unseen by a fresh subagent, so duplicate
// handling stays hands-off for them.
const isSubagent = typeof input.agent_id === "string" && input.agent_id.length > 0;
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 projectDir = normalizePath(getProjectDir());
const relToProject = normalizedFile.startsWith(projectDir)
? normalizedFile.slice(projectDir.length).replace(/^\//, "")
: "";
if (relToProject.startsWith(".wolf/") || relToProject.startsWith(".wolf\\")) {
process.exit(0);
// 2.4: .wolf reads used to be invisible to OpenWolf itself (~147k tokens
// of unmeasured self-inflicted reads, including whole-file reads of the
// two files the rules say never to read whole). Measure them (tagged
// separately so anatomy metrics stay clean) and steer the expensive
// pattern to the cheap one, once per file per session.
try {
const base = path.basename(normalizedFile);
const isRanged = input.tool_input?.offset !== undefined || input.tool_input?.limit !== undefined;
if (!isRanged && (base === "anatomy.md" || base === "cerebrum.md" || base === "memory.md")) {
let sizeTokens = 0;
try {
sizeTokens = Math.ceil(fs.statSync(filePath).size / 3.5);
}
catch { }
if (sizeTokens > 1500) {
const session = readJSON(sessionFile, {
session_id: "", files_read: {}, anatomy_hits: 0, anatomy_misses: 0, repeated_reads_warned: 0,
});
const warned = (session.wolf_read_advised ?? {});
if (!warned[base]) {
warned[base] = true;
session.wolf_read_advised = warned;
const note = base === "anatomy.md"
? `OpenWolf: ${base} is ~${sizeTokens} tokens and is an index, not a document. \`openwolf find <symbol or path>\` answers location queries in under 1k tokens; grep it for a single path's line otherwise.`
: `OpenWolf: ${base} is ~${sizeTokens} tokens. Grep the section you need (for example "## Do-Not-Repeat") or read with offset/limit; the whole file rarely pays for itself.`;
recordInjection(session, "wolf_read_advice", note);
writeJSON(sessionFile, session);
emitHookJSON("PreToolUse", { additionalContext: note });
}
}
}
}
catch { }
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);
if (isRangedRead) {
// Record the contact so dedupe knows about it, but flagged: a ranged
// first contact must never make a later full read look like a duplicate
// (the old asymmetry with post-read inflated warnings ~20x).
if (!session.files_read[normalizedFile]) {
session.files_read[normalizedFile] = {
count: 1, tokens: 0, first_read: new Date().toISOString(), ranged: true,
};
writeJSON(sessionFile, session);
}
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;
// Model-visible notes for this run — flushed as ONE additionalContext at exit.
const notes = [];
// Check if already read this session. Only a prior FULL read counts —
// ranged-only contact means the model has never seen the whole file.
if (session.files_read[normalizedFile] && session.files_read[normalizedFile].ranged !== true) {
const prev = session.files_read[normalizedFile];
let modifiedSinceRead = true;
try {
const mtime = fs.statSync(filePath).mtimeMs;
modifiedSinceRead = prev.read_mtime === undefined || mtime > prev.read_mtime;
}
catch { }
if (!modifiedSinceRead) {
const mode = duplicateMode(wolfDir);
session.repeated_reads_warned++;
// Denial is the only path that actually saves the tokens, but it must
// never strand the model: full reads only (ranged reads exited above),
// main thread only, only when the earlier read verifiably delivered
// content (tokens > 0), never after a compaction evicted that content,
// and only ONCE per file per session — the next attempt passes through.
const denyEligible = mode === "deny" && !isSubagent && prev.tokens > 0 &&
prev.denied_once !== true && prev.compacted !== true;
if (denyEligible) {
prev.denied_once = true;
session.reads_denied = (session.reads_denied ?? 0) + 1;
session.denied_tokens_saved = (session.denied_tokens_saved ?? 0) + prev.tokens;
const reason = `OpenWolf: ${path.basename(normalizedFile)} was already read this session (~${prev.tokens} tok) and is unchanged on disk. Reuse your earlier read, or use offset/limit for the exact lines you need. If you do need the full file again, a second attempt will pass through.`;
recordInjection(session, "dup_deny", reason);
writeJSON(sessionFile, session);
emitHookJSON("PreToolUse", {
permissionDecision: "deny",
permissionDecisionReason: reason,
});
return;
}
prev.count++;
if (mode !== "off") {
notes.push(`OpenWolf: ${path.basename(normalizedFile)} was already read this session (~${prev.tokens} tok), unchanged since. If you only need the gist, your earlier read may suffice; for exact text (edit anchors, line numbers), the re-read is fine.`);
}
if (notes.length > 0)
recordInjection(session, "dup_warn", notes.join("\n"));
writeJSON(sessionFile, session);
if (notes.length > 0) {
emitHookJSON("PreToolUse", { additionalContext: notes.join("\n") });
}
return;
}
delete session.files_read[normalizedFile];
}
// Anatomy lookup: O(1) against the durable store, legacy md scan fallback.
const entry = lookupEntry(wolfDir, projectDir, normalizedFile);
const found = entry !== null;
if (entry) {
if (entry.description) {
notes.push(`OpenWolf anatomy: ${entry.file}: ${entry.description} (~${entry.tokens} tok)`);
}
// Symbol hint (F2b Phase B): point at slices of big files. Suppressed if
// the on-disk file no longer matches what was indexed — a stale line
// range that misdirects an offset read is worse than no hint at all.
if (entry.symbols && entry.symbols.length > 0) {
let fresh = false;
try {
const st = fs.statSync(filePath);
fresh = (entry.size === undefined || st.size === entry.size) &&
(entry.mtimeMs === undefined || Math.abs(st.mtimeMs - entry.mtimeMs) < 1);
}
catch { }
if (fresh) {
// J2 skeleton hint: for big files with an indexed signature outline,
// the outline replaces the "largest sections" line — often enough to
// skip the full read entirely. Config-gated (reads.skeleton_hints).
if (entry.skeleton && entry.tokens > 2000 && skeletonHintsEnabled(wolfDir)) {
notes.push(`Signature outline of ${entry.file} (~${entry.tokens} tok full; symbols with line ranges follow):\n${entry.skeleton}\nRead with offset/limit to fetch just the part you need.`);
}
else {
const top = [...entry.symbols].sort((a, b) => b.tokens - a.tokens).slice(0, 5);
const list = top.map((s) => `${s.kind} ${s.name} L${s.startLine}-${s.endLine} ~${s.tokens} tok`).join("; ");
notes.push(`Largest sections: ${list}. Read with offset/limit to fetch just the part you need.`);
}
}
}
if (found)
break;
}
if (found) {
session.anatomy_hits++;
@ -68,13 +184,24 @@ async function main() {
session.anatomy_misses++;
}
// Record initial read entry (tokens will be updated in post-read)
let readMtime;
try {
readMtime = fs.statSync(filePath).mtimeMs;
}
catch { }
session.files_read[normalizedFile] = {
count: 1,
tokens: 0,
first_read: new Date().toISOString(),
read_mtime: readMtime,
anatomy_hit: found,
};
if (notes.length > 0)
recordInjection(session, "anatomy_hint", notes.join("\n"));
writeJSON(sessionFile, session);
process.exit(0);
if (notes.length > 0) {
emitHookJSON("PreToolUse", { additionalContext: notes.join("\n") });
}
}
main().catch(() => process.exit(0));
hookMain("pre-read", main);
//# sourceMappingURL=pre-read.js.map

View file

@ -1,6 +1,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { getWolfDir, ensureWolfDir, readJSON, readMarkdown, readStdin } from "./shared.js";
import { getWolfDir, ensureWolfDir, readBugLogFile, readMarkdown, readStdin, emitHookJSON, recordInjectionToSessionFile, hookMain, getSessionFilePath } from "./shared.js";
import { searchBugsFTS } from "./bug-index.js";
async function main() {
ensureWolfDir();
const wolfDir = getWolfDir();
@ -10,7 +11,6 @@ async function main() {
input = JSON.parse(raw);
}
catch {
process.exit(0);
return;
}
// For Edit tool, the meaningful content is old_string + new_string
@ -20,26 +20,31 @@ async function main() {
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;
}
// Model-visible notes — flushed as ONE additionalContext at exit.
const notes = [];
// 1. Cerebrum Do-Not-Repeat check
checkCerebrum(wolfDir, allContent);
notes.push(...checkCerebrum(wolfDir, allContent));
// 2. Bug log: search for similar past bugs when editing code
// This fires when Claude is about to edit a file — if the edit looks like a fix
// (changing error handling, modifying catch blocks, etc.), check the bug log
if (filePath && (oldStr || content)) {
checkBugLog(wolfDir, filePath, oldStr, newStr, content);
notes.push(...checkBugLog(wolfDir, filePath, oldStr, newStr, content));
}
if (notes.length > 0) {
recordInjectionToSessionFile(getSessionFilePath(input), "cerebrum_buglog", notes.join("\n"));
emitHookJSON("PreToolUse", { additionalContext: notes.join("\n") });
}
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;
return [];
const entries = doNotRepeatSection.split("## ")[0];
const lines = entries.split("\n").filter((l) => l.trim().startsWith("[") || l.trim().startsWith("-"));
const warnings = [];
for (const line of lines) {
const trimmed = line.trim().replace(/^[-*]\s*/, "").replace(/^\[[\d-]+\]\s*/, "");
if (!trimmed)
@ -58,12 +63,14 @@ function checkCerebrum(wolfDir, content) {
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`);
warnings.push(`OpenWolf cerebrum warning: "${trimmed}". Check this edit against that rule before proceeding.`);
break;
}
}
catch { }
}
}
return warnings.slice(0, 3);
}
// Common words that appear in most code — must be excluded from similarity matching
const STOP_WORDS = new Set([
@ -77,45 +84,64 @@ const STOP_WORDS = new Set([
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: [] });
return [];
const bugLog = readBugLogFile(wolfDir);
if (bugLog.bugs.length === 0)
return;
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`);
// J4: signature-keyed FTS retrieval first — finds relevant fixes across
// files, ranked by relevance, not just same-basename matches. Falls back to
// the legacy basename + overlap filter when node:sqlite is unavailable.
let relevant = null;
const ftsHits = searchBugsFTS(wolfDir, `${basename} ${editText.slice(0, 600)}`, 4);
if (ftsHits !== null) {
// Precision gate on the recall-oriented OR-query: keep same-file hits, and
// cross-file hits only with a tag or 3-word overlap with the edit.
const editTokens = tokenize(editText);
relevant = ftsHits.filter((bug) => {
if (path.basename(bug.file ?? "") === basename)
return true;
const tagHit = (bug.tags ?? []).some((t) => editText.includes(t.toLowerCase()));
if (tagHit)
return true;
const bugTokens = tokenize(bug.error_message + " " + bug.root_cause);
return [...editTokens].filter((t) => bugTokens.has(t)).length >= 3;
});
}
if (relevant === null) {
// Legacy path: ONLY surface bugs recorded against the SAME file.
const fileMatches = bugLog.bugs.filter(b => {
const bugBasename = path.basename(b.file);
return bugBasename === basename;
});
if (fileMatches.length === 0)
return [];
const editTokens = tokenize(editText);
relevant = fileMatches.filter(bug => {
const tagHit = bug.tags.some(t => editText.includes(t.toLowerCase()));
if (tagHit)
return true;
const bugTokens = tokenize(bug.error_message + " " + bug.root_cause);
const overlap = [...editTokens].filter(t => bugTokens.has(t));
return overlap.length >= 3;
});
}
if (relevant.length === 0)
return [];
// Surface as a FYI, not a directive — Claude should evaluate, not blindly apply
const lines = [
`OpenWolf buglog: ${relevant.length} past bug(s) recorded for ${basename}. Review for context; do NOT apply blindly:`,
];
for (const bug of relevant.slice(0, 2)) {
lines.push(`[${bug.id}] "${bug.error_message.slice(0, 70)}" | Cause: ${bug.root_cause.slice(0, 80)} | Fix: ${bug.fix.slice(0, 80)}`);
}
return lines;
}
function tokenize(text) {
return new Set(text.replace(/[^\w\s]/g, " ").split(/\s+/)
.filter(w => w.length > 3 && !STOP_WORDS.has(w.toLowerCase()))
.map(w => w.toLowerCase()));
}
main().catch(() => process.exit(0));
hookMain("pre-write", main);
//# sourceMappingURL=pre-write.js.map

View file

@ -1,6 +1,168 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { getWolfDir, ensureWolfDir, writeJSON, appendMarkdown, readJSON, timestamp, timeShort } from "./shared.js";
import { getWolfDir, ensureWolfDir, writeJSON, appendMarkdown, readJSON, readBugLogFile, timestamp, timeShort, estimateTokens, readStdin, detectAgent, recordInjectionToSessionFile, getProjectDir, hookMain, getSessionFilePath, gcSessionFiles } from "./shared.js";
import { topRules, scopedRulesForFiles } from "./rule-reinjection.js";
function digestBudget(wolfDir) {
const cfg = readJSON(path.join(wolfDir, "config.json"), {});
const ctx = cfg.openwolf?.context ?? {};
const agent = detectAgent();
return ctx.budgets?.[agent] ?? ctx.session_digest_budget_tokens ?? 1500;
}
/** Extract one `## heading` section (heading line through the next ## or ---). */
function extractSection(markdown, headingPattern) {
const lines = markdown.split(/\r?\n/);
const start = lines.findIndex((l) => headingPattern.test(l));
if (start === -1)
return "";
const out = [lines[start]];
for (let i = start + 1; i < lines.length; i++) {
if (/^## /.test(lines[i]) || /^---\s*$/.test(lines[i]))
break;
out.push(lines[i]);
}
return out.join("\n").trim();
}
/**
* True when this project's cerebrum was synced into Claude Code auto-memory
* (openwolf update leaves a marker in the memory index). Claude then recalls
* Do-Not-Repeat natively, so the digest must not inject it a second time.
*/
function claudeMemoryHasSync() {
if (detectAgent() !== "claude")
return false;
try {
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
if (!home)
return false;
const slug = path.resolve(getProjectDir()).replace(/[^a-zA-Z0-9]/g, "-");
const index = fs.readFileSync(path.join(home, ".claude", "projects", slug, "memory", "MEMORY.md"), "utf-8");
return index.includes("<!-- openwolf:begin -->");
}
catch {
return false;
}
}
/** Parse a leading YAML-ish frontmatter block for description/always keys (2.4). */
function readFrontmatter(content) {
if (!content.startsWith("---\n"))
return { body: content };
const end = content.indexOf("\n---", 4);
if (end === -1)
return { body: content };
const fm = content.slice(4, end);
const body = content.slice(content.indexOf("\n", end + 1) + 1);
const description = fm.match(/^description:\s*(.+)$/m)?.[1]?.trim();
const always = /^always:\s*true\s*$/m.test(fm);
return { description, always, body };
}
/**
* Progressive-disclosure digest (2.4). The old digest injected content
* (mean 794 tok, 75% of it a staleness nag or placeholder text). The new
* shape is an INDEX: one line per live .wolf file with what it is, how big,
* and how fresh, so the model can decide what to pull with grep or find —
* the retrieval pattern claude-mem, Letta, and the platform's own skills
* design converged on. Only two things still inject content: the top
* Do-Not-Repeat rules (small, highest value, unless native auto-memory
* already carries them) and any .wolf file whose frontmatter says
* `always: true` (budget-capped). Target: ~400 tokens.
*/
function buildSessionDigest(wolfDir, budget) {
budget = Math.min(budget, 400 + Math.floor(budget / 4));
const parts = [];
let used = 0;
const tryAdd = (text) => {
if (!text)
return false;
const cost = estimateTokens(text, "prose");
if (used + cost > budget)
return false;
parts.push(text);
used += cost;
return true;
};
const isPlaceholder = (l) => /_<[^>]*>_|\{\{[^}]*\}\}|^_?<[^>]*>_?$/.test(l.trim());
// 1. STATUS.md "next phase": the one piece of content that always earns its
// tokens on resume — but never template placeholders (8/28 measured digests
// were pure placeholder before this filter).
try {
const status = fs.readFileSync(path.join(wolfDir, "STATUS.md"), "utf-8");
const section = extractSection(readFrontmatter(status).body, /^## 🚀/);
const meaningful = section.split("\n").filter((l) => l.trim() && !isPlaceholder(l));
const contentLines = meaningful.filter((l) => !/^#|^\||^-+$/.test(l.trim()));
if (contentLines.length > 0)
tryAdd(meaningful.slice(0, 14).join("\n"));
}
catch { }
// 2. Top Do-Not-Repeat rules (skipped when native auto-memory carries them).
if (!claudeMemoryHasSync()) {
try {
const cerebrum = readFrontmatter(fs.readFileSync(path.join(wolfDir, "cerebrum.md"), "utf-8")).body;
const dnr = extractSection(cerebrum, /^## Do-Not-Repeat/);
const entries = dnr.split("\n").filter((l) => l.startsWith("- ") && !isPlaceholder(l));
if (entries.length > 0) {
tryAdd("Do-Not-Repeat (top rules from .wolf/cerebrum.md):\n" + entries.slice(-3).join("\n"));
}
}
catch { }
}
// 3. always:true files — the Letta-style explicit always-loaded set, capped.
try {
for (const f of fs.readdirSync(wolfDir)) {
if (!f.endsWith(".md") || f === "STATUS.md" || f === "OPENWOLF.md" || f === "anatomy.md")
continue;
const raw = fs.readFileSync(path.join(wolfDir, f), "utf-8");
const fm = readFrontmatter(raw);
if (fm.always) {
tryAdd(`## .wolf/${f} (always)\n` + fm.body.trim().split("\n").slice(0, 40).join("\n"));
}
}
}
catch { }
// 4. The index: one line per live state file. Pointers, not content.
try {
const indexLines = [];
const describe = {
"cerebrum.md": "learned preferences, conventions, Do-Not-Repeat rules",
"memory.md": "chronological action log per session",
"STATUS.md": "session handoff (regenerate with /handoff)",
"buglog.json": "known bugs with root causes and fixes (openwolf bug search)",
"anatomy.md": "project file index (query with openwolf find, never read whole)",
};
for (const [file, fallback] of Object.entries(describe)) {
const p = path.join(wolfDir, file);
let st;
try {
st = fs.statSync(p);
}
catch {
continue;
}
let desc = fallback;
let extra = "";
if (file === "buglog.json") {
const bugs = readBugLogFile(wolfDir).bugs.length;
if (bugs === 0)
continue;
extra = `, ${bugs} bugs`;
}
else if (file.endsWith(".md")) {
const fm = readFrontmatter(fs.readFileSync(p, "utf-8"));
if (fm.description)
desc = fm.description;
// A template-only file earns no index line.
if (fm.body.trim().length < 80)
continue;
}
const tok = Math.ceil(st.size / 4);
indexLines.push(`- .wolf/${file}: ${desc} (~${tok} tok${extra}, updated ${st.mtime.toISOString().slice(0, 10)})`);
}
if (indexLines.length > 0) {
tryAdd("OpenWolf state on disk (read on demand, not preloaded):\n" + indexLines.join("\n"));
}
}
catch { }
return parts.join("\n\n");
}
async function main() {
ensureWolfDir();
const wolfDir = getWolfDir();
@ -18,27 +180,65 @@ async function main() {
}
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
// SessionStart fires for startup/resume/clear/compact. Only startup and
// clear begin a genuinely new session — on resume/compact the session is
// still live, and resetting the session state would wipe read/write
// tracking mid-flight (Workstream F3: compaction survival).
let source = "startup";
let hookInput = {};
try {
hookInput = JSON.parse(await readStdin());
if (typeof hookInput.source === "string")
source = hookInput.source;
}
catch { }
// Session state is keyed by the harness session id (2.2): concurrent
// sessions in one project no longer cross-contaminate each other.
const sessionFile = getSessionFilePath(hookInput);
const sessionId = hookInput.session_id ??
`session-${now.toISOString().slice(0, 10)}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`;
gcSessionFiles();
// Hook install self-test (2.2): a missing dependency file once silently
// killed a hook for 3 weeks across 440 invocations. Verify every installed
// hook's relative imports resolve; surface breakage in the digest.
const brokenHooks = selfTestHooks(hooksDir);
const continuing = (source === "compact" || source === "resume") && fs.existsSync(sessionFile);
// Compaction evicts verbatim file contents from the model's context, so a
// post-compact re-read is legitimate: clear deny-eligibility on every
// recorded read (the warn path still fires; only denial is disarmed).
if (continuing && source === "compact") {
try {
const session = readJSON(sessionFile, {});
if (session.files_read) {
for (const entry of Object.values(session.files_read)) {
entry.compacted = true;
}
writeJSON(sessionFile, session);
}
}
catch { }
}
if (!continuing) {
writeJSON(sessionFile, {
session_id: sessionId,
started: timestamp(),
files_read: {},
files_written: [],
edit_counts: {},
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_warned: 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);
}
// Startup nudges — folded into the injected digest below (stderr from an
// exit-0 hook never reaches the model).
const startupNotes = [];
try {
const cerebrumPath = path.join(wolfDir, "cerebrum.md");
const cerebrumContent = fs.readFileSync(cerebrumPath, "utf-8");
@ -50,28 +250,115 @@ async function main() {
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`);
startupNotes.push(`cerebrum.md has only ${entryLines.length} entries. Record user preferences, project conventions, and mistakes to .wolf/cerebrum.md as you learn them this session.`);
}
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`);
startupNotes.push(`cerebrum.md hasn't been updated in ${Math.floor(daysSinceUpdate)} days. Look for opportunities to add learnings this session.`);
}
}
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`);
if (readBugLogFile(wolfDir).bugs.length === 0) {
startupNotes.push(`buglog.json is empty. If you encounter or fix any bugs, errors, or failed tests this session, log them to .wolf/buglog.json.`);
}
}
catch { }
// Increment total_sessions in token-ledger — only for a genuinely new
// session. Resume/compact re-fires SessionStart for a session already
// counted, which used to inflate the lifetime count.
if (!continuing) {
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);
}
// Inject the budget-capped digest into the model's context.
try {
let digest = buildSessionDigest(wolfDir, digestBudget(wolfDir));
// (The anatomy-staleness banner is gone in 2.2: it led 21 of 28 measured
// digests because a 6h rescan interval loses to normal commit cadence —
// a warning firing 75% of the time is a tax, not a signal. Freshness is
// the daemon's and scan's job, not the model's.)
// Hook breakage is the one warning worth its tokens: without it, a broken
// install silently loses every feature (happened for 3 weeks once).
if (brokenHooks.length > 0) {
digest = `OpenWolf hooks are degraded: ${brokenHooks.join("; ")}. Run \`openwolf update\` in this project to repair the install.\n\n` + digest;
}
// Post-compaction restore (F3 + 2.4): resurface what compaction erased.
// The platform documents that paths-scoped rules and nested CLAUDE.md are
// LOST at compaction until a matching file is read again — restore the
// scoped rules for files this session already touched, plus the top
// Do-Not-Repeat rules (decay countermeasure) and the in-flight state.
if (continuing && source === "compact") {
const session = readJSON(sessionFile, {});
const restoreParts = [];
const files = [...new Set((session.files_written ?? []).map((w) => w.file))];
if (files.length > 0) {
restoreParts.push(`## Session in progress (context was just compacted)\nFiles already modified this session: ${files.slice(-15).join(", ")}. Do not re-read them wholesale — check .wolf/memory.md for what was done.`);
}
try {
const rules = topRules(fs.readFileSync(path.join(wolfDir, "cerebrum.md"), "utf-8"), 3);
if (rules.length > 0) {
restoreParts.push(`Project rules still in effect (from .wolf/cerebrum.md Do-Not-Repeat):\n${rules.join("\n")}`);
}
}
catch { }
try {
const projectDir = getProjectDir();
const relFiles = [
...Object.keys(session.files_read ?? {}),
...files,
].map((f) => path.isAbsolute(f) ? path.relative(projectDir, f).split(path.sep).join("/") : f);
restoreParts.push(...scopedRulesForFiles(projectDir, relFiles));
}
catch { }
if (restoreParts.length > 0) {
digest = restoreParts.join("\n\n") + (digest ? "\n\n" + digest : "");
}
}
if (startupNotes.length > 0) {
digest = digest
? `${digest}\n\n${startupNotes.map((n) => `- ${n}`).join("\n")}`
: startupNotes.map((n) => `- ${n}`).join("\n");
}
if (digest) {
recordInjectionToSessionFile(sessionFile, "digest", digest);
process.stdout.write(JSON.stringify({
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: digest },
}));
}
}
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));
/**
* Verify every installed hook script's relative imports resolve (2.2). The
* 440-crash class was a missing DEPENDENCY file, not a missing hook, so a
* bare existence check of registered hooks would have missed it. Cheap static
* check: scan each installed hook script for relative import specifiers and
* verify the target files exist. Returns problem strings (empty = healthy).
*/
function selfTestHooks(hooksDir) {
const problems = [];
try {
const files = fs.readdirSync(hooksDir).filter((f) => f.endsWith(".js"));
for (const f of files) {
let src = "";
try {
src = fs.readFileSync(path.join(hooksDir, f), "utf-8");
}
catch {
problems.push(`${f} unreadable`);
continue;
}
for (const m of src.matchAll(/from\s+["']\.\/([\w.-]+\.js)["']/g)) {
if (!fs.existsSync(path.join(hooksDir, m[1]))) {
problems.push(`${f} cannot load ${m[1]}`);
}
}
}
}
catch { }
return [...new Set(problems)].slice(0, 3);
}
hookMain("session-start", main);
//# sourceMappingURL=session-start.js.map

View file

@ -1,10 +1,53 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as crypto from "node:crypto";
import { fileURLToPath } from "node:url";
// Prefer the harness-provided project dir so hooks work even if CWD changes
// during a session. Each supported agent exposes its own env var; hooks are
// provider-agnostic (Workstream C) so all are checked.
//
// None of those vars are guaranteed. Claude Code in particular does not put
// CLAUDE_PROJECT_DIR in the hook process's environment on any platform: it
// delivers project context through the stdin JSON payload instead. So before
// falling back to CWD, derive the root from this script's own location. A hook
// always runs as <project>/.wolf/hooks/<name>.js, which makes the project root
// two directories up, verified by the .wolf/ directory being there.
function projectDirFromScriptLocation() {
try {
const here = path.dirname(fileURLToPath(import.meta.url));
if (path.basename(here) !== "hooks")
return null;
const root = path.resolve(here, "..", "..");
if (path.basename(path.dirname(here)) !== ".wolf")
return null;
return fs.existsSync(path.join(root, ".wolf")) ? root : null;
}
catch {
return null;
}
}
export function getProjectDir() {
return (process.env.CLAUDE_PROJECT_DIR ||
process.env.CODEX_PROJECT_ROOT ||
process.env.OPENWOLF_PROJECT_ROOT ||
projectDirFromScriptLocation() ||
process.cwd());
}
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");
return path.join(getProjectDir(), ".wolf");
}
/** Which agent harness invoked this hook — used for per-agent ledger attribution. */
export function detectAgent() {
// CLAUDECODE is set in every Claude Code hook process; CLAUDE_PROJECT_DIR is
// not set at all, so checking it alone attributed real Claude sessions to
// "default" and lost their per-agent ledger rows.
if (process.env.CLAUDECODE || process.env.CLAUDE_CODE_ENTRYPOINT || process.env.CLAUDE_PROJECT_DIR)
return "claude";
if (process.env.CODEX_PROJECT_ROOT || process.env.CODEX_SANDBOX)
return "codex";
if (process.env.OPENCODE || process.env.OPENCODE_PROJECT_ROOT)
return "opencode";
return "default";
}
/**
* Bail out silently if .wolf/ directory doesn't exist in the current project.
@ -16,6 +59,86 @@ export function ensureWolfDir() {
process.exit(0);
}
}
// ─── Hook health (2.2): heartbeat + crash recording ──────────────────────────
// The PostToolUse write hook crashed on 100% of 440 invocations for 3 weeks
// with nothing noticing, because every hook swallowed its own errors
// (main().catch(() => exit(0))). Every hook now runs through hookMain(), which
// records a per-hook heartbeat (last success / last error / consecutive
// failures) that session-start, update, and the dashboard can check.
export const HEARTBEAT_FILE = "_heartbeat.json";
export function recordHeartbeat(hookName, error) {
try {
const file = path.join(getWolfDir(), "hooks", HEARTBEAT_FILE);
const beats = readJSON(file, {});
const entry = beats[hookName] ?? { consecutive_failures: 0 };
if (error === undefined) {
entry.last_ok = new Date().toISOString();
entry.consecutive_failures = 0;
}
else {
entry.last_error = new Date().toISOString();
entry.last_error_message = String(error instanceof Error ? error.stack ?? error.message : error).slice(0, 500);
entry.consecutive_failures = (entry.consecutive_failures ?? 0) + 1;
}
beats[hookName] = entry;
writeJSON(file, beats);
}
catch { }
}
/**
* Standard hook entry point: runs the hook, records a heartbeat either way,
* always exits 0 (hooks must never block the agent). `--selfcheck` exits
* immediately after module load: reaching this code at all proves every static
* import resolved, which is exactly the failure class that went undetected.
*/
export function hookMain(hookName, fn) {
if (process.argv.includes("--selfcheck")) {
process.stdout.write(`ok ${hookName}`);
process.exit(0);
}
Promise.resolve()
.then(fn)
.then(() => {
recordHeartbeat(hookName);
process.exit(0);
})
.catch((err) => {
recordHeartbeat(hookName, err);
process.exit(0);
});
}
// ─── Session-keyed state (2.2) ───────────────────────────────────────────────
// _session.json used to be one per-PROJECT file, so concurrent Claude sessions
// cross-contaminated each other's read tracking (one of the two causes of the
// ledger's ~20x duplicate-warning inflation). State is now keyed by the
// harness-provided session_id when present.
/** Resolve the session state file for this hook invocation. */
export function getSessionFilePath(hookInput) {
const hooksDir = path.join(getWolfDir(), "hooks");
const id = hookInput?.session_id;
if (typeof id === "string" && /^[\w.-]{4,128}$/.test(id)) {
return path.join(hooksDir, "sessions", `${id}.json`);
}
// Legacy fallback for agents that pass no session id.
return path.join(hooksDir, "_session.json");
}
/** Delete session state files older than maxAgeDays (called from session-start). */
export function gcSessionFiles(maxAgeDays = 7) {
try {
const dir = path.join(getWolfDir(), "hooks", "sessions");
const cutoff = Date.now() - maxAgeDays * 24 * 3600 * 1000;
for (const f of fs.readdirSync(dir)) {
if (!f.endsWith(".json"))
continue;
try {
if (fs.statSync(path.join(dir, f)).mtimeMs < cutoff)
fs.unlinkSync(path.join(dir, f));
}
catch { }
}
}
catch { }
}
export function readJSON(filePath, fallback) {
try {
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
@ -24,6 +147,24 @@ export function readJSON(filePath, fallback) {
return fallback;
}
}
/**
* Reads .wolf/buglog.json in any shape a project might have on disk and always
* returns { version, bugs }. A hand-written log is often a bare array of
* entries; that shape used to reach `bugLog.bugs.length` and throw, which is
* how pre-write racked up 465 consecutive failures on one project before
* anyone noticed (the hook heartbeat was the only witness).
*/
export function readBugLogFile(wolfDir) {
const raw = readJSON(path.join(wolfDir, "buglog.json"), null);
if (Array.isArray(raw))
return { version: 1, bugs: raw };
if (raw && typeof raw === "object") {
const bugs = raw.bugs;
if (Array.isArray(bugs))
return { ...raw, version: 1, bugs };
}
return { version: 1, bugs: [] };
}
export function writeJSON(filePath, data) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir))
@ -60,50 +201,30 @@ export function appendMarkdown(filePath, line) {
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");
// parseAnatomy / serializeAnatomy / AnatomyEntry moved to ./anatomy-store.ts —
// the single canonical home of the anatomy format (OPENWOLF-2.0 §F2b).
// Files whose contents (or content-derived descriptions) must never reach
// anatomy.md / memory.md because they hold secrets (issue #54). Kept in sync
// with the copy in src/scanner/anatomy-scanner.ts — hooks are standalone
// scripts and the scanner cannot be imported from here.
const SENSITIVE_EXTENSIONS = new Set([
".pem", ".key", ".p8", ".p12", ".pfx", ".keystore", ".jks", ".ppk", ".kdbx", ".tfstate",
]);
const SENSITIVE_BASENAMES = new Set([".npmrc", ".netrc", ".htpasswd", ".pgpass"]);
export function isSensitiveFile(basename) {
const lower = basename.toLowerCase();
if (lower === ".env" || lower.startsWith(".env."))
return true;
if (SENSITIVE_BASENAMES.has(lower))
return true;
const dot = lower.lastIndexOf(".");
if (dot >= 0 && SENSITIVE_EXTENSIONS.has(lower.slice(dot)))
return true;
if (/^id_(rsa|dsa|ecdsa|ed25519)/.test(lower))
return true;
if (lower.includes("credential") || /^secrets\.(json|ya?ml|toml)$/.test(lower))
return true;
return false;
}
export function extractDescription(filePath) {
const MAX_DESC = 150;
@ -611,4 +732,121 @@ export function readStdin() {
export function normalizePath(p) {
return p.replace(/\\/g, "/");
}
export function emitHookJSON(hookEventName, fields) {
const out = { hookEventName };
if (fields.additionalContext)
out.additionalContext = fields.additionalContext;
if (fields.updatedToolOutput !== undefined)
out.updatedToolOutput = fields.updatedToolOutput;
if (fields.permissionDecision) {
out.permissionDecision = fields.permissionDecision;
out.permissionDecisionReason = fields.permissionDecisionReason ?? "";
}
if (Object.keys(out).length === 1)
return;
process.stdout.write(JSON.stringify({ hookSpecificOutput: out }));
}
/** Record an injected text against the in-memory session (caller persists). */
export function recordInjection(session, source, text) {
if (!text)
return;
const tokens = estimateTokens(text, "prose");
session.injected_tokens_estimated = (session.injected_tokens_estimated ?? 0) + tokens;
const bySource = session.injected_by_source ?? {};
bySource[source] = (bySource[source] ?? 0) + tokens;
session.injected_by_source = bySource;
}
/** Same, for hooks that do not otherwise hold the session file open. */
export function recordInjectionToSessionFile(sessionFile, source, text) {
if (!text)
return;
try {
const session = readJSON(sessionFile, {});
recordInjection(session, source, text);
writeJSON(sessionFile, session);
}
catch { }
}
/**
* Count non-mechanical semantic entries written to memory.md this session.
* Mechanical entries (auto-generated file ops, session-end lines) don't count.
* Used by the stop hook to detect whether Claude wrote a meaningful summary.
*/
export function countSemanticEntries(wolfDir) {
const memoryPath = path.join(wolfDir, "memory.md");
try {
const content = fs.readFileSync(memoryPath, "utf-8");
const mechanical = /^\|\s*[\d\-: ]+\|\s*(Created|Edited|Multi-edited|Session end:|designqc:)/;
const tableHeader = /^\|\s*Time\s*\|/i;
const tableSeparator = /^\|[\s\-|]+\|?\s*$/;
const lines = content.split("\n");
let start = -1;
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].startsWith("## Session: ")) {
start = i;
break;
}
}
let count = 0;
for (let i = start + 1; i < lines.length; i++) {
const line = lines[i];
if (!line.startsWith("|"))
continue;
if (tableHeader.test(line) || tableSeparator.test(line) || mechanical.test(line))
continue;
count++;
}
return count;
}
catch {
return 0;
}
}
export function readTranscriptUsage(transcriptPath) {
let raw;
try {
raw = fs.readFileSync(transcriptPath, "utf-8");
}
catch {
return null;
}
// One usage block per API call; streaming can emit several transcript lines
// for one message id, and a resumed session can replay a message under a new
// request — dedupe on message id + request id, keeping the last usage seen.
const byId = new Map();
let anon = 0;
for (const line of raw.split("\n")) {
if (!line.trim())
continue;
try {
const entry = JSON.parse(line);
const usage = entry?.message?.usage;
if (usage && typeof usage === "object" && typeof usage.output_tokens === "number") {
const key = `${entry.message.id ?? `anon-${anon++}`}:${entry.requestId ?? ""}`;
byId.set(key, { ...usage, model: entry.message.model });
}
}
catch { }
}
if (byId.size === 0)
return null;
const total = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, api_calls: byId.size };
const perModel = {};
for (const u of byId.values()) {
total.input_tokens += u.input_tokens ?? 0;
total.output_tokens += u.output_tokens ?? 0;
total.cache_read_input_tokens += u.cache_read_input_tokens ?? 0;
total.cache_creation_input_tokens += u.cache_creation_input_tokens ?? 0;
const model = u.model ?? "unknown";
const m = perModel[model] ?? (perModel[model] = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, api_calls: 0 });
m.input_tokens += u.input_tokens ?? 0;
m.output_tokens += u.output_tokens ?? 0;
m.cache_read_input_tokens += u.cache_read_input_tokens ?? 0;
m.cache_creation_input_tokens += u.cache_creation_input_tokens ?? 0;
m.api_calls++;
}
if (Object.keys(perModel).length > 0)
total.per_model = perModel;
return total;
}
//# sourceMappingURL=shared.js.map

View file

@ -1,18 +1,18 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { execFileSync } from "node:child_process";
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, appendMarkdown, timeShort } from "./shared.js";
import { getWolfDir, ensureWolfDir, readJSON, writeJSON, countSemanticEntries, readStdin, hookMain, getSessionFilePath } from "./shared.js";
import { buildSessionEntry, flushSessionToLedger } from "./ledger.js";
import { verifyHookDelivery } from "./hook-attachments.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();
// Runs every stop regardless of read/write activity this turn, and
// regardless of whether buglog.json was edited via the Write/Edit tool
// or via a raw script (the common path for a file this large) — this
// is the only guard that can't be bypassed by which tool touched it.
checkAndCheckpointBuglog(wolfDir, projectRoot);
// Stop payload → transcript path for real usage measurement (F1)
let hookInput = {};
try {
hookInput = JSON.parse(await readStdin());
}
catch { }
const sessionFile = getSessionFilePath(hookInput);
const session = readJSON(sessionFile, {
session_id: "",
started: "",
@ -22,8 +22,8 @@ async function main() {
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_warned: 0,
cerebrum_warnings: 0,
stop_count: 0,
reminders_sent: {},
});
session.stop_count++;
// Only write to ledger if there's been activity
@ -31,206 +31,117 @@ async function main() {
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 { }
// Collect end-of-turn reminders. Each fires at most ONCE per session, and
// they are QUEUED rather than emitted: Stop additionalContext forces a full
// continuation turn (the model re-sends the whole conversation to respond),
// so the UserPromptSubmit hook drains the queue into the next user turn's
// context instead — same visibility, zero extra turns.
if (!session.reminders_sent)
session.reminders_sent = {};
const reminderChecks = [
["buglog", checkForMissingBugLogs(wolfDir, session)],
["cerebrum", checkCerebrumFreshness(wolfDir, session)],
["semantic", checkSemanticSummaries(wolfDir, session)],
];
const reminders = [];
for (const [key, message] of reminderChecks) {
if (message === null)
continue;
const sent = session.reminders_sent[key] ?? 0;
if (sent >= 1)
continue;
session.reminders_sent[key] = sent + 1;
reminders.push(message);
}
if (reminders.length > 0) {
if (!session.pending_reminders)
session.pending_reminders = [];
session.pending_reminders.push(`OpenWolf end-of-turn reminders:\n${reminders.map((r) => `- ${r}`).join("\n")}`);
}
// Idempotent ledger write: the entry for this session id is REPLACED, not
// appended — Stop fires every turn, and appending per turn is what used to
// duplicate sessions and quadratically inflate lifetime totals.
const entry = buildSessionEntry(session, hookInput.transcript_path);
// Verified delivery (2.2): the transcript records every hook invocation as
// an attachment line; that is ground truth for what fired, failed, and what
// context actually reached the model — self-reported counters are estimates.
if (hookInput.transcript_path) {
const verified = verifyHookDelivery(hookInput.transcript_path);
if (verified)
entry.verified = verified;
}
flushSessionToLedger(wolfDir, entry);
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.
* Returns a reminder string if action is needed, otherwise null.
*/
function checkForMissingBugLogs(wolfDir, session) {
if (!session.edit_counts)
return;
return null;
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`);
return null;
let buglogWritten = false;
try {
const stat = fs.statSync(path.join(wolfDir, "buglog.json"));
const sessionStartMs = session.started ? Date.parse(session.started) : 0;
buglogWritten = sessionStartMs > 0 && stat.mtimeMs >= sessionStartMs;
}
catch { }
if (!buglogWritten) {
return `ACTION REQUIRED: Files edited 3+ times this session (${multiEditFiles.join(", ")}) but buglog.json was not updated. Log the bug fixes to .wolf/buglog.json now.`;
}
return null;
}
/**
* Check if STATUS.md is older than the session start AND there was meaningful
* code activity (3+ writes outside .wolf/). If so, nudge Claude to update
* STATUS.md so the next /clear has fresh handoff context.
*/
// (The STATUS.md staleness nag was removed in 2.1: STATUS.md is regenerated
// on demand by the /handoff skill instead of being nagged about every turn.)
/**
* 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.
* a while and there was significant activity, return a 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`);
return `ACTION REQUIRED: cerebrum.md hasn't been updated in ${Math.floor(hoursSinceUpdate)}h and ${session.files_written.length} files were modified. Update .wolf/cerebrum.md with any new user preferences, conventions, or gotchas discovered this session.`;
}
}
catch {
// cerebrum.md doesn't exist, that's ok
}
return null;
}
/**
* Validate .wolf/buglog.json on disk and, if it's well-formed and grew
* (or stayed the same) relative to the last commit, checkpoint it as its
* own standalone commit. Never commits a shrinking or malformed file —
* that would enshrine data loss (or corruption) into git history, which
* is exactly the failure mode this guard exists to catch before it repeats.
* Check if a semantic summary was written to memory.md this session.
* Returns a reminder string if action is needed, otherwise null.
*/
function checkAndCheckpointBuglog(wolfDir, projectRoot) {
const buglogPath = path.join(wolfDir, "buglog.json");
if (!fs.existsSync(buglogPath))
return;
let raw;
try {
raw = fs.readFileSync(buglogPath, "utf-8");
}
catch {
return;
}
let parsed;
try {
parsed = JSON.parse(raw);
}
catch (e) {
process.stderr.write(`🛑 OpenWolf: .wolf/buglog.json is not valid JSON (${e.message}). NOT checkpointing — fix this before it's lost for good.\n`);
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !Array.isArray(parsed.bugs)) {
process.stderr.write(`🛑 OpenWolf: .wolf/buglog.json must be {"version":1,"bugs":[...]}, not a bare array or other shape. NOT checkpointing.\n`);
return;
}
const bugs = parsed.bugs;
const idNum = (id) => {
const m = /^bug-(\d+)$/.exec(id || "");
return m ? parseInt(m[1], 10) : 0;
};
const currentMaxId = bugs.reduce((max, b) => Math.max(max, idNum(b.id)), 0);
const idCounts = {};
for (const b of bugs)
idCounts[b.id] = (idCounts[b.id] || 0) + 1;
const dupes = Object.keys(idCounts).filter((id) => idCounts[id] > 1);
if (dupes.length > 0) {
process.stderr.write(`⚠️ OpenWolf: buglog.json has duplicate ids (${dupes.slice(0, 5).join(", ")}${dupes.length > 5 ? ", ..." : ""}). Before adding a new bug, search for an existing entry with the same file+root_cause and bump its occurrences instead of appending a new one under a colliding or fresh id.\n`);
}
let lastCount = null;
let lastMaxId = null;
try {
const committed = execFileSync("git", ["show", "HEAD:.wolf/buglog.json"], {
cwd: projectRoot,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
});
const lastParsed = JSON.parse(committed);
const lastBugs = Array.isArray(lastParsed) ? lastParsed : (lastParsed.bugs || []);
lastCount = lastBugs.length;
lastMaxId = lastBugs.reduce((max, b) => Math.max(max, idNum(b.id)), 0);
}
catch {
// No committed version yet (or git unavailable) — nothing to compare against.
}
if (lastCount !== null && bugs.length < lastCount) {
process.stderr.write(`🛑 OpenWolf: buglog.json SHRANK from ${lastCount} to ${bugs.length} bugs since the last commit. NOT auto-committing — this looks like data loss, investigate (check git history / other worktrees) before committing manually.\n`);
return;
}
if (lastMaxId !== null && currentMaxId < lastMaxId && bugs.length > 0) {
process.stderr.write(`🛑 OpenWolf: buglog.json's highest id (bug-${currentMaxId}) is lower than the last commit's (bug-${lastMaxId}) — looks like the id counter was reset. NOT auto-committing.\n`);
return;
}
try {
const status = execFileSync("git", ["status", "--porcelain", "--", ".wolf/buglog.json"], {
cwd: projectRoot,
encoding: "utf-8",
});
if (!status.trim())
return; // nothing changed since last commit
execFileSync("git", ["add", ".wolf/buglog.json"], { cwd: projectRoot });
execFileSync("git", ["commit", "-m", `chore(wolf): checkpoint buglog.json (${bugs.length} bugs)`], { cwd: projectRoot });
process.stderr.write(`✅ OpenWolf: checkpointed buglog.json (${bugs.length} bugs) to git.\n`);
}
catch (e) {
process.stderr.write(`⚠️ OpenWolf: buglog.json is valid but the checkpoint commit failed: ${e.message}\n`);
function checkSemanticSummaries(wolfDir, session) {
const writeCount = session.files_written.length;
if (writeCount < 2)
return null;
const semanticCount = countSemanticEntries(wolfDir);
if (semanticCount === 0) {
return `ACTION REQUIRED: ${writeCount} files were modified this session but no semantic summary was written to memory.md. Append a one-line summary: | HH:MM | description | file(s) | outcome | ~tokens |`;
}
return null;
}
// Run only when executed as a hook script — never on import (tests import
// from this module, and main() exits the process).
import { pathToFileURL } from "node:url";
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
hookMain("stop", main);
}
main().catch(() => process.exit(0));
//# sourceMappingURL=stop.js.map