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

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

View file

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