Files
sabia/.opencode/plugin/openwolf/post-write.ts
T
Lucas e989413018 Esqueleto inicial: sabia-core + sabia-cli + UI local, OpenWolf
- sabia-core: descoberta de interfaces, controle de modo monitor/canal
  via iw (ponte até ter nl80211 nativo), captura crua 802.11 via
  AF_PACKET (sem libpcap), parsing de radiotap e quadros de
  gerenciamento (beacon/probe/EAPOL), agregação em tabela de APs/
  clientes, escrita de .pcap, trilha de auditoria encadeada por hash,
  auto-atualização via GitHub releases (curl, sem dependências de
  rede extras)
- sabia-cli: `sabia iface`, `sabia scan`, `sabia update`,
  `sabia audit-verify`, `sabia ui` (servidor local + navegador,
  protegido por token, mesma filosofia do Guará)
- OpenWolf inicializado para memória de projeto entre agentes
2026-10-23 08:22:04 -03:00

261 lines
11 KiB
TypeScript

import { persistObservation, reconcileReads } from "./event-journal.js"
import * as crypto from "node:crypto"
import { recordBug } from "./bug-journal.js"
import { sharedWolfDir } from "./knowledge-root.js";
import { withFileLock, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js"
import { nextBugId } from "./bug-id.js"
import * as fs from "node:fs"
import * as path from "node:path"
import { getWolfDir, writeJSON, readJSON, appendMarkdown, timeShort, normalizePath, estimateTokens, isSensitiveFile, sessionFilePath } from "./fs.js"
import { extractDescription, withAnatomyLock, loadStoreReconciled, saveStore, renderToFile, sha256, LOCK_BUDGET_MS } from "./anatomy.js"
import type { PartialSessionState, FixDetection } from "./types.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",
])
export function handlePostWrite(
directory: string,
sessionId: string,
toolName: string,
filePath: string,
content: string,
oldStr: string,
newStr: string
): void {
const wolfDir = getWolfDir(directory)
if (!fs.existsSync(wolfDir)) return
const hooksDir = path.join(wolfDir, "hooks")
const sessionFile = sessionFilePath(hooksDir, sessionId)
const projectRoot = directory
const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath)
const relPath = normalizePath(path.relative(projectRoot, absolutePath))
if (relPath.startsWith(".wolf/")) return
// Never track files outside the project root (e.g. a scratchpad under
// /private/tmp). path.relative() yields ../.. section keys that pollute
// anatomy.md and are wiped again by every full 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 (isSensitiveFile(baseName)) return
updateAnatomy(wolfDir, absolutePath, projectRoot, content)
appendToMemory(wolfDir, toolName, absolutePath, projectRoot, content, newStr)
trackSession(sessionFile, filePath, toolName, content, newStr, baseName, projectRoot, absolutePath)
if (oldStr && newStr) {
autoDetectBugFix(wolfDir, absolutePath, projectRoot, oldStr, newStr)
}
}
function updateAnatomy(wolfDir: string, absolutePath: string, projectRoot: string, content: string): void {
try {
const relPathLocal = normalizePath(path.relative(projectRoot, absolutePath))
let fileContent = ""
try {
fileContent = fs.readFileSync(absolutePath, "utf-8")
} catch {
fileContent = content ?? ""
}
const desc = extractDescription(absolutePath).slice(0, 100)
const ext = path.extname(absolutePath).toLowerCase()
const codeExts = new Set([".ts", ".js", ".tsx", ".jsx", ".py", ".json", ".yaml", ".yml", ".css"])
const proseExts = new Set([".md", ".txt", ".rst"])
const type = codeExts.has(ext) ? "code" : proseExts.has(ext) ? "prose" : "mixed"
const tokens = estimateTokens(fileContent, type as "code" | "prose" | "mixed")
let size: number | undefined
let mtimeMs: number | undefined
try {
const st = fs.statSync(absolutePath)
size = st.size
mtimeMs = st.mtimeMs
} catch {}
withAnatomyLock(wolfDir, 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",
// The plugin cannot recompute symbols; drop them so stale line
// ranges never misdirect a slice read (the next scan restores them).
symbols: undefined,
}
store.meta.lastScanned = new Date().toISOString()
renderToFile(wolfDir, store)
saveStore(wolfDir, store)
})
} catch {}
}
function appendToMemory(
wolfDir: string,
toolName: string,
absolutePath: string,
projectRoot: string,
content: string,
newStr: string
): void {
try {
const action = toolName === "Write" ? "Created" : toolName === "MultiEdit" ? "Multi-edited" : "Edited"
const relFile = normalizePath(path.relative(projectRoot, absolutePath))
const fileContent = content ?? ""
const ext = path.extname(absolutePath).toLowerCase()
const codeExts = new Set([".ts", ".js", ".tsx", ".jsx", ".py", ".json", ".yaml", ".yml", ".css"])
const type = codeExts.has(ext) ? "code" : "mixed"
const writeTokens = estimateTokens(fileContent || newStr, type as "code" | "prose" | "mixed")
let changeDesc = ""
if (content && newStr) {
changeDesc = summarizeEdit(content, newStr, path.basename(absolutePath))
}
const memoryPath = path.join(wolfDir, "memory.md")
const outcome = changeDesc || "—"
appendMarkdown(memoryPath, `| ${timeShort()} | ${action} ${relFile} | ${outcome} | ~${writeTokens} |\n`)
} catch {}
}
function trackSession(
sessionFile: string,
filePath: string,
toolName: string,
content: string,
newStr: string,
baseName: string,
projectRoot: string,
absolutePath: string
): void {
try {
const normalizedFile=normalizePath(absolutePath)
const action=toolName.toLowerCase()==="write"?"create":"edit"
const tokens=estimateTokens(content || newStr,"code")
const editKey=normalizePath(path.relative(projectRoot,absolutePath))
persistObservation(sessionFile,{id:crypto.randomUUID(),kind:"write",file:normalizedFile,action,tokens,editKey,at:new Date().toISOString()})
reconcileReads(sessionFile)
} catch {}
}
export function summarizeEdit(oldStr: string, newStr: string, filename: string): string {
const oldLines = oldStr.split("\n")
const newLines = newStr.split("\n")
const oldCount = oldLines.length
const newCount = newLines.length
if (newStr.includes("try") && newStr.includes("catch") && !oldStr.includes("catch")) return "added error handling"
if (newStr.includes("?.") && !oldStr.includes("?.")) return "added optional chaining"
if (newStr.includes("?? ") && !oldStr.includes("?? ")) return "added nullish coalescing"
if (!newStr.trim() || newStr.trim().length < oldStr.trim().length * 0.2) return `removed ${oldCount} lines`
const oldImports = oldLines.filter(l => /^\s*(import|require|use |from )/.test(l)).length
const newImports = newLines.filter(l => /^\s*(import|require|use |from )/.test(l)).length
if (newImports > oldImports && Math.abs(newCount - oldCount) <= newImports - oldImports + 1) return `added ${newImports - oldImports} import(s)`
if (oldCount === 1 && newCount === 1) {
const o = oldStr.trim()
const n = newStr.trim()
const oStr = o.match(/['"`]([^'"`]+)['"`]/)
const nStr = n.match(/['"`]([^'"`]+)['"`]/)
if (oStr && nStr && oStr[1] !== nStr[1]) return `"${oStr[1].slice(0, 25)}" → "${nStr[1].slice(0, 25)}"`
return "inline fix"
}
const fnMatch = newStr.match(/(?:function|def|fn|func|async\s+function)\s+(\w+)/)
if (fnMatch) return `modified ${fnMatch[1]}()`
if (newCount > oldCount + 5) return `expanded (+${newCount - oldCount} lines)`
if (oldCount > newCount + 5) return `reduced (-${oldCount - newCount} lines)`
return `${oldCount}→${newCount} lines`
}
function bugAutoDetectEnabled(wolfDir: string): boolean {
try {
const cfg = readJSON<{ openwolf?: { buglog?: { auto_detect?: boolean } } }>(
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
}
}
export function autoDetectBugFix(wolfDir: string, absolutePath: string, projectRoot: string, oldStr: string, newStr: string): void {
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 detection=detectFixPattern(oldStr,newStr,ext,basename)
if (!detection) return
recordBug(wolfDir,{error_message:detection.summary,file:normalizePath(path.relative(projectRoot,absolutePath)),root_cause:detection.rootCause,fix:detection.fix,tags:["auto-detected",detection.category,ext.slice(1)||"unknown"],status:"candidate",observed_worktree:projectRoot})
}
export function detectFixPattern(oldStr: string, newStr: string, ext: string, basename: string): FixDetection | null {
const oldLines = oldStr.split("\n")
const newLines = newStr.split("\n")
if (!/(?:Test|IT|Spec)\.\w+$|(?:[._](?:test|spec))\.\w+$|^test_/.test(basename) && /\bcatch\s*\(/.test(newStr) && !/\bcatch\s*\(/.test(oldStr)) {
const fn = newStr.match(/(?:function|def|async)\s+(\w+)/)?.[1] || basename
return { category: "error-handling", summary: `Missing error handling in ${fn}`, rootCause: "Code path had no error handling", fix: "Added try/catch block", context: extractChangedLines(oldStr, newStr) }
}
if ((newStr.includes("?.") && !oldStr.includes("?.")) || (newStr.includes("?? ") && !oldStr.includes("?? "))) {
return { category: "null-safety", summary: `Null/undefined access in ${basename}`, rootCause: "Property access on potentially null/undefined value", fix: "Added null safety", context: extractChangedLines(oldStr, newStr) }
}
if (/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 { category: "guard-clause", summary: "Missing guard clause", rootCause: `No early return for: ${condition}`, fix: `Added guard clause: if (${condition.slice(0, 40)})` }
}
if (oldLines.length <= 3 && newLines.length <= 3) {
const oStrs = oldStr.trim().match(/['"`]([^'"`]{2,})['"`]/g) || []
const nStrs = newStr.trim().match(/['"`]([^'"`]{2,})['"`]/g) || []
if (oStrs.length > 0 && nStrs.length > 0) {
for (let i = 0; i < Math.min(oStrs.length, nStrs.length); i++) {
if (oStrs[i] !== nStrs[i]) {
return { category: "wrong-value", summary: "Incorrect value in code", rootCause: `Had ${oStrs[i].slice(0, 50)}`, fix: `Changed to ${nStrs[i].slice(0, 50)}` }
}
}
}
}
if (newStr.includes("await ") && !oldStr.includes("await ")) {
return { category: "async-fix", summary: "Missing await", rootCause: "Async call without await", fix: "Added await to async call", context: extractChangedLines(oldStr, newStr) }
}
return null
}
function extractChangedLines(oldStr: string, newStr: string): string {
const oldLines = new Set(oldStr.split("\n").map(l => l.trim()).filter(Boolean))
const added = newStr.split("\n").map(l => l.trim()).filter(l => l && !oldLines.has(l))
return added.slice(0, 2).map(l => l.slice(0, 60)).join("; ")
}