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
|
|
@ -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