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,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
|
||||
Loading…
Add table
Add a link
Reference in a new issue