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:
parent
13246a89c8
commit
d8bf8fb2e1
27 changed files with 7702 additions and 897158 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue