myxce/.wolf/hooks/stop.js
Rongsong Shen ba4e5fd227
chore(wolf): guard buglog.json integrity, add dedupe rule and auto-checkpoint
Recovered .wolf/buglog.json (828 entries lost when this worktree's copy
silently reset to bug-001 sometime after commit ce96fd6d, 3 days
uncommitted with zero overlap detection) by merging the committed
history back in and renumbering the 43 real entries logged since.

To stop this recurring:
- OPENWOLF.md: require searching for an existing same-file/root_cause
  entry before appending a new bug id (fixes the bug-1418 x4 duplication
  pattern found in the recovered history); never restart the id counter.
- stop.js: validate buglog.json every turn (JSON validity, {version,bugs}
  wrapper, duplicate ids, count/max-id not regressing vs. the last
  commit) and auto-commit it standalone when it grew cleanly, so git
  history is always a recoverable backup instead of a 3-day-stale one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 04:28:44 +00:00

236 lines
No EOL
10 KiB
JavaScript

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";
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);
const session = readJSON(sessionFile, {
session_id: "",
started: "",
files_read: {},
files_written: [],
edit_counts: {},
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_warned: 0,
cerebrum_warnings: 0,
stop_count: 0,
});
session.stop_count++;
// Only write to ledger if there's been activity
const readCount = Object.keys(session.files_read).length;
const writeCount = session.files_written.length;
if (readCount === 0 && writeCount === 0) {
writeJSON(sessionFile, session);
process.exit(0);
return;
}
// Check for files edited many times without a buglog entry
checkForMissingBugLogs(wolfDir, session);
// Check if cerebrum was updated this session (it should be if there were edits)
checkCerebrumFreshness(wolfDir, session);
// Build session entry for ledger
const reads = Object.entries(session.files_read).map(([file, data]) => ({
file,
tokens_estimated: data.tokens,
was_repeated: data.count > 1,
anatomy_had_description: false, // simplified
}));
const writes = session.files_written.map((w) => ({
file: w.file,
tokens_estimated: w.tokens,
action: w.action,
}));
const inputTokens = reads.reduce((sum, r) => sum + r.tokens_estimated, 0);
const outputTokens = writes.reduce((sum, w) => sum + w.tokens_estimated, 0);
const sessionEntry = {
id: session.session_id,
started: session.started,
ended: new Date().toISOString(),
reads,
writes,
totals: {
input_tokens_estimated: inputTokens,
output_tokens_estimated: outputTokens,
reads_count: readCount,
writes_count: writeCount,
repeated_reads_blocked: session.repeated_reads_warned,
anatomy_lookups: session.anatomy_hits,
},
};
// Update token-ledger.json
const ledgerPath = path.join(wolfDir, "token-ledger.json");
const ledger = readJSON(ledgerPath, {
version: 1,
created_at: "",
lifetime: {
total_tokens_estimated: 0,
total_reads: 0,
total_writes: 0,
total_sessions: 0,
anatomy_hits: 0,
anatomy_misses: 0,
repeated_reads_blocked: 0,
estimated_savings_vs_bare_cli: 0,
},
sessions: [],
daemon_usage: [],
waste_flags: [],
optimization_report: { last_generated: null, patterns: [] },
});
ledger.sessions.push(sessionEntry);
ledger.lifetime.total_reads += readCount;
ledger.lifetime.total_writes += writeCount;
ledger.lifetime.total_tokens_estimated += inputTokens + outputTokens;
ledger.lifetime.anatomy_hits += session.anatomy_hits;
ledger.lifetime.anatomy_misses += session.anatomy_misses;
ledger.lifetime.repeated_reads_blocked += session.repeated_reads_warned;
// Estimate savings: anatomy hits save ~200 tokens each, repeated reads blocked save their token count
const savedFromAnatomy = session.anatomy_hits * 200;
const savedFromRepeats = Object.values(session.files_read)
.filter((r) => r.count > 1)
.reduce((sum, r) => sum + r.tokens * (r.count - 1), 0);
ledger.lifetime.estimated_savings_vs_bare_cli += savedFromAnatomy + savedFromRepeats;
writeJSON(ledgerPath, ledger);
// Write a session summary line to memory.md if there was meaningful activity
if (writeCount > 0) {
try {
const uniqueFiles = new Set(session.files_written.map(w => path.basename(w.file)));
const fileList = [...uniqueFiles].slice(0, 5).join(", ");
const memoryPath = path.join(wolfDir, "memory.md");
appendMarkdown(memoryPath, `| ${timeShort()} | Session end: ${writeCount} writes across ${uniqueFiles.size} files (${fileList}) | ${readCount} reads | ~${inputTokens + outputTokens} tok |\n`);
}
catch { }
}
writeJSON(sessionFile, session);
process.exit(0);
}
/**
* Check if files were edited multiple times but buglog.json wasn't updated.
* Emit a stderr reminder so Claude sees it in the next turn.
*/
function checkForMissingBugLogs(wolfDir, session) {
if (!session.edit_counts)
return;
const multiEditFiles = Object.entries(session.edit_counts)
.filter(([, count]) => count >= 3)
.map(([file]) => path.basename(file));
if (multiEditFiles.length === 0)
return;
// Check if buglog was written to this session
const buglogWritten = session.files_written.some(w => w.file.includes("buglog.json"));
if (!buglogWritten) {
process.stderr.write(`⚠️ OpenWolf: Files edited 3+ times this session (${multiEditFiles.join(", ")}) but buglog.json was not updated. If you fixed bugs, please log them.\n`);
}
}
/**
* Check if cerebrum.md was updated recently. If it hasn't been updated in
* a while and there was significant activity, emit a gentle reminder.
*/
function checkCerebrumFreshness(wolfDir, session) {
const cerebrumPath = path.join(wolfDir, "cerebrum.md");
try {
const stat = fs.statSync(cerebrumPath);
const hoursSinceUpdate = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60);
// If cerebrum hasn't been updated in 24h+ and there were significant writes
if (hoursSinceUpdate > 24 && session.files_written.length >= 3) {
process.stderr.write(`💡 OpenWolf: cerebrum.md hasn't been updated in ${Math.floor(hoursSinceUpdate)}h. Did you learn any user preferences, conventions, or gotchas this session? Consider updating .wolf/cerebrum.md.\n`);
}
}
catch {
// cerebrum.md doesn't exist, that's ok
}
}
/**
* 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.
*/
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`);
}
}
main().catch(() => process.exit(0));
//# sourceMappingURL=stop.js.map