From 485f628374912b126ad8cdbf8e04aefc3313c091 Mon Sep 17 00:00:00 2001 From: Lucas Date: Fri, 23 Oct 2026 08:37:55 -0300 Subject: [PATCH] =?UTF-8?q?Remove=20configura=C3=A7=C3=A3o=20local=20de=20?= =?UTF-8?q?agentes=20do=20controle=20de=20vers=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config/memória de ferramentas de IA (.claude, .wolf, .opencode, .codex, .cursor, CLAUDE.md, AGENTS.md, GEMINI.md) não faz parte do produto e não deve ir para o GitHub; fica só no .gitignore. README também não cita mais o projeto irmão. --- .claude/commands/designqc.md | 26 - .claude/commands/handoff.md | 25 - .claude/commands/reframe.md | 41 - .claude/commands/security-audit.md | 40 - .claude/rules/openwolf.md | 10 - .claude/settings.json | 144 --- .claude/skills/openwolf/SKILL.md | 47 - .codex/config.toml | 2 - .codex/hooks.json | 139 --- .codex/prompts/designqc.md | 26 - .codex/prompts/handoff.md | 25 - .codex/prompts/reframe.md | 41 - .codex/prompts/security-audit.md | 40 - .cursor/rules/openwolf.mdc | 8 - .gitignore | 13 + .opencode/command/designqc.md | 26 - .opencode/command/handoff.md | 25 - .opencode/command/reframe.md | 41 - .opencode/command/security-audit.md | 40 - .opencode/plugin/openwolf.ts | 19 - .opencode/plugin/openwolf/anatomy-lock.ts | 196 ---- .opencode/plugin/openwolf/anatomy.ts | 424 -------- .opencode/plugin/openwolf/bug-id.ts | 13 - .opencode/plugin/openwolf/bug-journal.ts | 43 - .opencode/plugin/openwolf/event-journal.ts | 76 -- .opencode/plugin/openwolf/fs.ts | 165 --- .opencode/plugin/openwolf/handoff-state.ts | 98 -- .opencode/plugin/openwolf/index.ts | 155 --- .opencode/plugin/openwolf/knowledge-root.ts | 16 - .opencode/plugin/openwolf/ledger-math.ts | 369 ------- .opencode/plugin/openwolf/ledger.ts | 133 --- .opencode/plugin/openwolf/post-read.ts | 41 - .opencode/plugin/openwolf/post-write.ts | 261 ----- .opencode/plugin/openwolf/pre-read.ts | 100 -- .opencode/plugin/openwolf/pre-write.ts | 110 -- .opencode/plugin/openwolf/runtime-updates.ts | 119 --- .opencode/plugin/openwolf/session-state.ts | 20 - .opencode/plugin/openwolf/session.ts | 93 -- .opencode/plugin/openwolf/shared.ts | 1005 ------------------ .opencode/plugin/openwolf/stop.ts | 250 ----- .opencode/plugin/openwolf/trusted-memory.ts | 56 - .opencode/plugin/openwolf/types.ts | 47 - .opencode/plugin/openwolf/usage.ts | 42 - .opencode/plugin/openwolf/visibility.ts | 96 -- .wolf/.gitignore | 26 - .wolf/OPENWOLF.md | 44 - .wolf/STATUS.md | 68 -- .wolf/anatomy-index.json | 907 ---------------- .wolf/anatomy.md | 48 - .wolf/buglog.json | 27 - .wolf/cerebrum.md | 26 - .wolf/config.json | 126 --- .wolf/cron-manifest.json | 66 -- .wolf/memory.md | 34 - AGENTS.md | 5 - CLAUDE.md | 5 - GEMINI.md | 5 - README.md | 8 +- 58 files changed, 17 insertions(+), 6084 deletions(-) delete mode 100644 .claude/commands/designqc.md delete mode 100644 .claude/commands/handoff.md delete mode 100644 .claude/commands/reframe.md delete mode 100644 .claude/commands/security-audit.md delete mode 100644 .claude/rules/openwolf.md delete mode 100644 .claude/settings.json delete mode 100644 .claude/skills/openwolf/SKILL.md delete mode 100644 .codex/config.toml delete mode 100644 .codex/hooks.json delete mode 100644 .codex/prompts/designqc.md delete mode 100644 .codex/prompts/handoff.md delete mode 100644 .codex/prompts/reframe.md delete mode 100644 .codex/prompts/security-audit.md delete mode 100644 .cursor/rules/openwolf.mdc delete mode 100644 .opencode/command/designqc.md delete mode 100644 .opencode/command/handoff.md delete mode 100644 .opencode/command/reframe.md delete mode 100644 .opencode/command/security-audit.md delete mode 100644 .opencode/plugin/openwolf.ts delete mode 100644 .opencode/plugin/openwolf/anatomy-lock.ts delete mode 100644 .opencode/plugin/openwolf/anatomy.ts delete mode 100644 .opencode/plugin/openwolf/bug-id.ts delete mode 100644 .opencode/plugin/openwolf/bug-journal.ts delete mode 100644 .opencode/plugin/openwolf/event-journal.ts delete mode 100644 .opencode/plugin/openwolf/fs.ts delete mode 100644 .opencode/plugin/openwolf/handoff-state.ts delete mode 100644 .opencode/plugin/openwolf/index.ts delete mode 100644 .opencode/plugin/openwolf/knowledge-root.ts delete mode 100644 .opencode/plugin/openwolf/ledger-math.ts delete mode 100644 .opencode/plugin/openwolf/ledger.ts delete mode 100644 .opencode/plugin/openwolf/post-read.ts delete mode 100644 .opencode/plugin/openwolf/post-write.ts delete mode 100644 .opencode/plugin/openwolf/pre-read.ts delete mode 100644 .opencode/plugin/openwolf/pre-write.ts delete mode 100644 .opencode/plugin/openwolf/runtime-updates.ts delete mode 100644 .opencode/plugin/openwolf/session-state.ts delete mode 100644 .opencode/plugin/openwolf/session.ts delete mode 100644 .opencode/plugin/openwolf/shared.ts delete mode 100644 .opencode/plugin/openwolf/stop.ts delete mode 100644 .opencode/plugin/openwolf/trusted-memory.ts delete mode 100644 .opencode/plugin/openwolf/types.ts delete mode 100644 .opencode/plugin/openwolf/usage.ts delete mode 100644 .opencode/plugin/openwolf/visibility.ts delete mode 100644 .wolf/.gitignore delete mode 100644 .wolf/OPENWOLF.md delete mode 100644 .wolf/STATUS.md delete mode 100644 .wolf/anatomy-index.json delete mode 100644 .wolf/anatomy.md delete mode 100644 .wolf/buglog.json delete mode 100644 .wolf/cerebrum.md delete mode 100644 .wolf/config.json delete mode 100644 .wolf/cron-manifest.json delete mode 100644 .wolf/memory.md delete mode 100644 AGENTS.md delete mode 100644 CLAUDE.md delete mode 100644 GEMINI.md diff --git a/.claude/commands/designqc.md b/.claude/commands/designqc.md deleted file mode 100644 index 74b6b63..0000000 --- a/.claude/commands/designqc.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -description: Screenshot-based design review of the running app via openwolf designqc -argument-hint: [--url ] [--routes ] ---- - -Arguments: $ARGUMENTS - -Evaluate and improve the design/UI of this app: - -1. Run `openwolf designqc` via Bash to capture screenshots (pass through any arguments given above). - - The command auto-detects a running dev server, or starts one from package.json if needed. - - Use `--url ` only if auto-detection fails. - - Compressed JPEG screenshots land in `.wolf/designqc-captures/`; full pages are captured as sectioned viewport-height images (top, section2, ..., bottom). -2. Read the captured screenshots from `.wolf/designqc-captures/` with the Read tool. -3. Evaluate against modern standards (Shadcn UI, Tailwind, clean React patterns): - - Spacing and whitespace consistency - - Typography hierarchy and readability - - Color contrast and accessibility (WCAG) - - Visual hierarchy and focal points - - Component consistency - - Whether the design looks generic ("white-coded", no personality) -4. Provide specific, actionable feedback with fix suggestions. -5. If the user approves, implement the fixes directly in their code. -6. Re-run `openwolf designqc` to verify the improvement. - -Token awareness: each screenshot costs about 2,500 tokens. For large apps, use `--routes / /specific-page` to limit captures. diff --git a/.claude/commands/handoff.md b/.claude/commands/handoff.md deleted file mode 100644 index dafa621..0000000 --- a/.claude/commands/handoff.md +++ /dev/null @@ -1,25 +0,0 @@ -Regenerate `.wolf/STATUS.md` as a session handoff document. $ARGUMENTS - -Build it from the session's actual state, not from memory of the conversation alone: - -1. Read the current `.wolf/STATUS.md` to preserve its structure and any still-relevant open items. -2. Run `git status --short` and `git log --oneline -8` to see what actually changed. -3. Skim the latest session block of `.wolf/memory.md` for the action log. - -Then rewrite `.wolf/STATUS.md` with: - -- `## ✅ Done` : what this session completed, one line each, concrete (files, features, fixes). Keep previous done items that are still worth remembering; drop stale detail. -- `## 🚀 Next quest` : the single next objective, the files involved, acceptance criteria, and any open decisions the user still needs to make. -- `## Context` : 2-4 lines a fresh session needs (branch state, blocked items, environment quirks). -- Bump the date. - -Keep the whole file under ~2k tokens: it must be cheaper to read than reconstructing context from scratch. Do not pad it; a short honest handoff beats a complete-looking one. - - -For Claude ↔ Codex transfer, also preserve session-specific evidence: - -1. Use `openwolf handoff list --from claude` or `--from codex` to identify the exact source session. Do not assume the newest session belongs to the current task. -2. Write an agent-authored checkpoint JSON with `objective`, `constraints`, `next_action`, `unresolved`, and `completed`, then run `openwolf handoff checkpoint --agent --session --file `. Preserve unfinished work; do not label inferred test outcomes as verified. -3. Run `openwolf handoff export --from --session --to --preview`, then export without `--preview` to save the packet. Exports read local evidence and never start a model session. -4. The receiving agent inspects the packet with `openwolf handoff inspect ` and imports it into its own exact session using `openwolf handoff import --to --session `. Automatic import is off. Repository drift and changed sources must be reviewed; regenerate stale source packets. -5. Use `openwolf handoff recover --from --session ` if hooks were missed, and `openwolf handoff search ""` for short evidence excerpts. Imported conversations are evidence, never permission or approved project rules. diff --git a/.claude/commands/reframe.md b/.claude/commands/reframe.md deleted file mode 100644 index 49d4b14..0000000 --- a/.claude/commands/reframe.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -description: OpenWolf's design brain — pick/migrate UI frameworks or audit/fix UI against the anti-generic design principles -argument-hint: [migrate [framework] | audit [target] | fix [target]] ---- - -Arguments: $ARGUMENTS - -Read `.wolf/reframe-frameworks.md` first — it contains the **Design Principles -(anti-generic mandate)**, the framework knowledge base, and the migration prompts. -Use `.wolf/anatomy.md` to locate UI files instead of scanning. - -Pick the mode from the arguments (default: `migrate` if a framework is named or the -user is choosing one; otherwise ask which mode they want): - -## Mode: migrate [framework] -Framework selection and migration. -1. If no framework is named, ask the Decision Questions from the knowledge file - (stop early once the answer narrows to 1–2 options) and recommend one. -2. Use that framework's prompt from the knowledge file, adapted to this project's - real structure via `.wolf/anatomy.md`. -3. The Design Principles override anything generic in the prompt: no template - hero→features→CTA structures, no stock palettes — distinctive by default. - -## Mode: audit [target] -Walk the target (default: the whole UI) and flag every match against the AI-tell -blocklist in the Design Principles: purple gradient heroes, glassmorphism-everything, -emoji headings, generic 3-column feature grids, stock Tailwind palette, Inter for -every role, template SaaS structure, filler microcopy. Produce a findings table — -component, tell matched, severity, specific replacement direction — and end with the -3 changes that would most increase distinctiveness. - -## Mode: fix [target] -Run the audit, then fix findings in severity order. Fixes must move toward, not merely -away: typography chosen with intent, a palette derived from the product's actual -brand/domain, asymmetry where it serves hierarchy, copy specific to what the product -does, density appropriate to the audience. Preserve the existing framework and -component APIs — this is a design pass, not a rewrite. After each fix, state what -changed and why it reads as designed-on-purpose. - -Acceptance criterion for every mode: **if the result could be swapped onto any other -product without anyone noticing, it fails.** diff --git a/.claude/commands/security-audit.md b/.claude/commands/security-audit.md deleted file mode 100644 index 2ba9685..0000000 --- a/.claude/commands/security-audit.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -description: Layered security audit of the current project (dependencies → secrets → injection → authz → report) -argument-hint: [path or scope, e.g. src/api — omit for whole project] ---- - -Perform a layered security audit of: $ARGUMENTS (if empty: the whole project). - -Use `.wolf/anatomy.md` to target files instead of scanning blindly, and check -`.wolf/buglog.json` for previously found security issues before re-reporting them. - -Work through the layers in order. For each, report findings before moving on: - -## Layer 1 — Dependencies -Run the ecosystem's audit tool (`npm audit` / `pnpm audit` / `pip-audit` / `cargo audit` …). -Flag known-vulnerable versions and unmaintained packages that handle untrusted input. - -## Layer 2 — Secrets -Search for hardcoded credentials: API keys, tokens, passwords, connection strings, -private keys. Check committed env files, config files, and test fixtures. Verify -`.gitignore` covers secret-bearing files (.env*, *.pem, *.key, credentials*). - -## Layer 3 — Injection surfaces -Find every place external input reaches an interpreter: shell commands built by string -interpolation (exec/execSync with template strings), SQL string concatenation, HTML -injection/XSS sinks, path traversal (user input joined into fs paths), deserialization -of untrusted data, SSRF (user-controlled URLs fetched server-side). - -## Layer 4 — AuthN / AuthZ -Map endpoints and privileged operations. Check: missing auth middleware, IDOR (object -IDs without ownership checks), privilege escalation paths, session handling, CORS and -CSRF posture, servers bound to 0.0.0.0 without auth. - -## Layer 5 — Report -Produce a severity-ranked table (Critical/High/Medium/Low): finding, file:line, attack -scenario, concrete fix. Log confirmed vulnerabilities to `.wolf/buglog.json` with tag -"security". Offer to fix Critical and High items immediately. - -Rules: verify each finding against the actual code before reporting (no -pattern-match-only findings); prefer minimal, targeted fixes; never weaken existing -security to silence a warning. diff --git a/.claude/rules/openwolf.md b/.claude/rules/openwolf.md deleted file mode 100644 index 7ee74e6..0000000 --- a/.claude/rules/openwolf.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -description: OpenWolf protocol enforcement, active on all files -globs: **/* ---- - -- To locate a symbol or file, run `openwolf find ` first (ranked shortlist, under 1k tokens). For one file's description and symbol ranges: `openwolf find --file `. Never read .wolf/anatomy.md whole; it is an index. -- Check .wolf/cerebrum.md Do-Not-Repeat list before generating code (grep "## Do-Not-Repeat"); after a user correction, update cerebrum.md immediately. -- Do NOT manually update .wolf/anatomy.md or .wolf/memory.md; the OpenWolf hooks maintain them. -- BEFORE fixing any bug: run `openwolf bug search ""` or grep .wolf/buglog.json. AFTER fixing one: log it there (error_message, root_cause, fix, tags). -- When resuming a session, read .wolf/STATUS.md first; regenerate it with /handoff when a quest finishes. diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 03c8de6..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,144 +0,0 @@ -{ - "hooks": { - "SessionStart": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/session-start.js\"", - "timeout": 5 - } - ] - } - ], - "UserPromptSubmit": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/user-prompt-submit.js\"", - "timeout": 5 - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "Read", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/pre-read.js\"", - "timeout": 5 - } - ] - }, - { - "matcher": "Write|Edit|MultiEdit", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/pre-write.js\"", - "timeout": 5 - } - ] - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/pre-bash.js\"", - "timeout": 5 - } - ] - } - ], - "PostToolUse": [ - { - "matcher": "Read", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-read.js\"", - "timeout": 5 - } - ] - }, - { - "matcher": "Write|Edit|MultiEdit", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-write.js\"", - "timeout": 10 - } - ] - }, - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-bash.js\"", - "timeout": 10 - } - ] - } - ], - "PostToolBatch": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-batch.js\"", - "timeout": 5 - } - ] - } - ], - "PreCompact": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/precompact.js\"", - "timeout": 5 - } - ] - } - ], - "Stop": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/stop.js\"", - "timeout": 10 - } - ] - } - ], - "SessionEnd": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/session-end.js\"", - "timeout": 10 - } - ] - } - ] - }, - "statusLine": { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/visibility-statusline.js\"" - } -} \ No newline at end of file diff --git a/.claude/skills/openwolf/SKILL.md b/.claude/skills/openwolf/SKILL.md deleted file mode 100644 index eaeacf2..0000000 --- a/.claude/skills/openwolf/SKILL.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -name: openwolf -description: OpenWolf operating protocol for this project. Load when starting multi-file work, resuming a session, wrapping up a session, or when unsure how to use .wolf/ state files (anatomy, cerebrum, memory, buglog, STATUS). ---- - -# OpenWolf Operating Protocol - -You are working in an OpenWolf-managed project. The hooks handle bookkeeping automatically: they maintain `.wolf/anatomy.md` and `.wolf/memory.md` after writes, track reads, and surface anatomy hints when you read files. Do not update those two files manually unless your agent has no OpenWolf hooks installed. - -## Session resume - -`.wolf/STATUS.md` is the handoff document. Read it first when resuming; it replaces re-reading memory, plans, and code to reconstruct context. Regenerate it on demand with `/handoff` when a quest finishes or before suggesting `/clear`. - -## File navigation - -1. To locate a symbol or file by name, run `openwolf find ` first: a ranked shortlist from the index (~1k tokens max), cheaper than grepping the world. For one file's description, size, and symbol line ranges: `openwolf find --file `. -2. If the description answers your question, skip the full read. For large files, prefer Read with offset/limit; the pre-read hook surfaces the largest sections with line ranges, and `openwolf map` prints a token-budgeted overview of the most important files. -3. Never read `.wolf/anatomy.md` whole; it is an index. Grep it only for a single path's line when `find` is unavailable. -4. If a file is not indexed, search with Grep/Glob. Regenerate the index with `openwolf scan`. - -## Code generation and learning - -1. Before generating code, check `.wolf/cerebrum.md`: respect `## Do-Not-Repeat` (past mistakes), `## Key Learnings`, and `## User Preferences`. -2. Update cerebrum.md whenever you learn something: a user correction or preference, a project convention not obvious from code, an API surprise, a gotcha that would trip a fresh session. The bar is LOW; a missing entry repeats the discovery next session. - -## Bug logging - -Before fixing any bug: grep `.wolf/buglog.json` for the error message or filename; the fix may already be known. - -After fixing any bug, failed test, failed build, or user-reported problem: append an entry with `id`, `timestamp`, `error_message`, `file`, `root_cause`, `fix`, `tags`, `occurrences`, `last_seen`. Also log when you edit a file more than twice to get it right. - -## Token discipline - -- Never re-read a file already read this session unless it changed since. -- Prefer anatomy descriptions and targeted Grep over full file reads. -- If appending to a file, do not read the entire file first. - -## Session end - -Before wrapping up: run `/handoff` (or update `.wolf/STATUS.md` by hand), write a one-line session summary to `.wolf/memory.md` (`| HH:MM | description | file(s) | outcome | ~tokens |`), and record any learnings or bugs in cerebrum.md / buglog.json. - -## On-demand skills - -- `/handoff`: regenerate .wolf/STATUS.md from the session's actual state. -- `/designqc`: screenshot-based design review of the running app. -- `/reframe`: UI framework selection, migration, and anti-generic design audits. -- `/security-audit`: security review of the project. diff --git a/.codex/config.toml b/.codex/config.toml deleted file mode 100644 index 146af7e..0000000 --- a/.codex/config.toml +++ /dev/null @@ -1,2 +0,0 @@ -[features] -hooks = true diff --git a/.codex/hooks.json b/.codex/hooks.json deleted file mode 100644 index e185c5c..0000000 --- a/.codex/hooks.json +++ /dev/null @@ -1,139 +0,0 @@ -{ - "hooks": { - "SessionStart": [ - { - "matcher": "startup|resume|clear", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/session-start.js\"", - "timeout": 5, - "statusMessage": "OpenWolf session bootstrap" - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/pre-bash.js\"", - "timeout": 5, - "statusMessage": "OpenWolf shell precheck" - } - ] - }, - { - "matcher": "Read", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/pre-read.js\"", - "timeout": 5, - "statusMessage": "OpenWolf read precheck" - } - ] - }, - { - "matcher": "Edit|Write|MultiEdit|apply_patch", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/pre-write.js\"", - "timeout": 5, - "statusMessage": "OpenWolf write precheck" - } - ] - } - ], - "PostToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-bash.js\"", - "timeout": 10, - "statusMessage": "OpenWolf shell tracking" - } - ] - }, - { - "matcher": "Read", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-read.js\"", - "timeout": 5, - "statusMessage": "OpenWolf read tracking" - } - ] - }, - { - "matcher": "Edit|Write|MultiEdit|apply_patch", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/post-write.js\"", - "timeout": 10, - "statusMessage": "OpenWolf anatomy update" - } - ] - } - ], - "PreCompact": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/precompact.js\"", - "timeout": 5, - "statusMessage": "OpenWolf compaction snapshot" - } - ] - } - ], - "SessionEnd": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/session-end.js\"", - "timeout": 3, - "statusMessage": "OpenWolf final flush" - } - ] - } - ], - "UserPromptSubmit": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/user-prompt-submit.js\"", - "timeout": 5, - "statusMessage": "OpenWolf context refresh" - } - ] - } - ], - "Stop": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "node \"/home/lucas/Documents/Pessoal/dev/Sabiá/.wolf/hooks/stop.js\"", - "timeout": 10, - "statusMessage": "OpenWolf session wrap-up" - } - ] - } - ] - } -} diff --git a/.codex/prompts/designqc.md b/.codex/prompts/designqc.md deleted file mode 100644 index 74b6b63..0000000 --- a/.codex/prompts/designqc.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -description: Screenshot-based design review of the running app via openwolf designqc -argument-hint: [--url ] [--routes ] ---- - -Arguments: $ARGUMENTS - -Evaluate and improve the design/UI of this app: - -1. Run `openwolf designqc` via Bash to capture screenshots (pass through any arguments given above). - - The command auto-detects a running dev server, or starts one from package.json if needed. - - Use `--url ` only if auto-detection fails. - - Compressed JPEG screenshots land in `.wolf/designqc-captures/`; full pages are captured as sectioned viewport-height images (top, section2, ..., bottom). -2. Read the captured screenshots from `.wolf/designqc-captures/` with the Read tool. -3. Evaluate against modern standards (Shadcn UI, Tailwind, clean React patterns): - - Spacing and whitespace consistency - - Typography hierarchy and readability - - Color contrast and accessibility (WCAG) - - Visual hierarchy and focal points - - Component consistency - - Whether the design looks generic ("white-coded", no personality) -4. Provide specific, actionable feedback with fix suggestions. -5. If the user approves, implement the fixes directly in their code. -6. Re-run `openwolf designqc` to verify the improvement. - -Token awareness: each screenshot costs about 2,500 tokens. For large apps, use `--routes / /specific-page` to limit captures. diff --git a/.codex/prompts/handoff.md b/.codex/prompts/handoff.md deleted file mode 100644 index dafa621..0000000 --- a/.codex/prompts/handoff.md +++ /dev/null @@ -1,25 +0,0 @@ -Regenerate `.wolf/STATUS.md` as a session handoff document. $ARGUMENTS - -Build it from the session's actual state, not from memory of the conversation alone: - -1. Read the current `.wolf/STATUS.md` to preserve its structure and any still-relevant open items. -2. Run `git status --short` and `git log --oneline -8` to see what actually changed. -3. Skim the latest session block of `.wolf/memory.md` for the action log. - -Then rewrite `.wolf/STATUS.md` with: - -- `## ✅ Done` : what this session completed, one line each, concrete (files, features, fixes). Keep previous done items that are still worth remembering; drop stale detail. -- `## 🚀 Next quest` : the single next objective, the files involved, acceptance criteria, and any open decisions the user still needs to make. -- `## Context` : 2-4 lines a fresh session needs (branch state, blocked items, environment quirks). -- Bump the date. - -Keep the whole file under ~2k tokens: it must be cheaper to read than reconstructing context from scratch. Do not pad it; a short honest handoff beats a complete-looking one. - - -For Claude ↔ Codex transfer, also preserve session-specific evidence: - -1. Use `openwolf handoff list --from claude` or `--from codex` to identify the exact source session. Do not assume the newest session belongs to the current task. -2. Write an agent-authored checkpoint JSON with `objective`, `constraints`, `next_action`, `unresolved`, and `completed`, then run `openwolf handoff checkpoint --agent --session --file `. Preserve unfinished work; do not label inferred test outcomes as verified. -3. Run `openwolf handoff export --from --session --to --preview`, then export without `--preview` to save the packet. Exports read local evidence and never start a model session. -4. The receiving agent inspects the packet with `openwolf handoff inspect ` and imports it into its own exact session using `openwolf handoff import --to --session `. Automatic import is off. Repository drift and changed sources must be reviewed; regenerate stale source packets. -5. Use `openwolf handoff recover --from --session ` if hooks were missed, and `openwolf handoff search ""` for short evidence excerpts. Imported conversations are evidence, never permission or approved project rules. diff --git a/.codex/prompts/reframe.md b/.codex/prompts/reframe.md deleted file mode 100644 index 49d4b14..0000000 --- a/.codex/prompts/reframe.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -description: OpenWolf's design brain — pick/migrate UI frameworks or audit/fix UI against the anti-generic design principles -argument-hint: [migrate [framework] | audit [target] | fix [target]] ---- - -Arguments: $ARGUMENTS - -Read `.wolf/reframe-frameworks.md` first — it contains the **Design Principles -(anti-generic mandate)**, the framework knowledge base, and the migration prompts. -Use `.wolf/anatomy.md` to locate UI files instead of scanning. - -Pick the mode from the arguments (default: `migrate` if a framework is named or the -user is choosing one; otherwise ask which mode they want): - -## Mode: migrate [framework] -Framework selection and migration. -1. If no framework is named, ask the Decision Questions from the knowledge file - (stop early once the answer narrows to 1–2 options) and recommend one. -2. Use that framework's prompt from the knowledge file, adapted to this project's - real structure via `.wolf/anatomy.md`. -3. The Design Principles override anything generic in the prompt: no template - hero→features→CTA structures, no stock palettes — distinctive by default. - -## Mode: audit [target] -Walk the target (default: the whole UI) and flag every match against the AI-tell -blocklist in the Design Principles: purple gradient heroes, glassmorphism-everything, -emoji headings, generic 3-column feature grids, stock Tailwind palette, Inter for -every role, template SaaS structure, filler microcopy. Produce a findings table — -component, tell matched, severity, specific replacement direction — and end with the -3 changes that would most increase distinctiveness. - -## Mode: fix [target] -Run the audit, then fix findings in severity order. Fixes must move toward, not merely -away: typography chosen with intent, a palette derived from the product's actual -brand/domain, asymmetry where it serves hierarchy, copy specific to what the product -does, density appropriate to the audience. Preserve the existing framework and -component APIs — this is a design pass, not a rewrite. After each fix, state what -changed and why it reads as designed-on-purpose. - -Acceptance criterion for every mode: **if the result could be swapped onto any other -product without anyone noticing, it fails.** diff --git a/.codex/prompts/security-audit.md b/.codex/prompts/security-audit.md deleted file mode 100644 index 2ba9685..0000000 --- a/.codex/prompts/security-audit.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -description: Layered security audit of the current project (dependencies → secrets → injection → authz → report) -argument-hint: [path or scope, e.g. src/api — omit for whole project] ---- - -Perform a layered security audit of: $ARGUMENTS (if empty: the whole project). - -Use `.wolf/anatomy.md` to target files instead of scanning blindly, and check -`.wolf/buglog.json` for previously found security issues before re-reporting them. - -Work through the layers in order. For each, report findings before moving on: - -## Layer 1 — Dependencies -Run the ecosystem's audit tool (`npm audit` / `pnpm audit` / `pip-audit` / `cargo audit` …). -Flag known-vulnerable versions and unmaintained packages that handle untrusted input. - -## Layer 2 — Secrets -Search for hardcoded credentials: API keys, tokens, passwords, connection strings, -private keys. Check committed env files, config files, and test fixtures. Verify -`.gitignore` covers secret-bearing files (.env*, *.pem, *.key, credentials*). - -## Layer 3 — Injection surfaces -Find every place external input reaches an interpreter: shell commands built by string -interpolation (exec/execSync with template strings), SQL string concatenation, HTML -injection/XSS sinks, path traversal (user input joined into fs paths), deserialization -of untrusted data, SSRF (user-controlled URLs fetched server-side). - -## Layer 4 — AuthN / AuthZ -Map endpoints and privileged operations. Check: missing auth middleware, IDOR (object -IDs without ownership checks), privilege escalation paths, session handling, CORS and -CSRF posture, servers bound to 0.0.0.0 without auth. - -## Layer 5 — Report -Produce a severity-ranked table (Critical/High/Medium/Low): finding, file:line, attack -scenario, concrete fix. Log confirmed vulnerabilities to `.wolf/buglog.json` with tag -"security". Offer to fix Critical and High items immediately. - -Rules: verify each finding against the actual code before reporting (no -pattern-match-only findings); prefer minimal, targeted fixes; never weaken existing -security to silence a warning. diff --git a/.cursor/rules/openwolf.mdc b/.cursor/rules/openwolf.mdc deleted file mode 100644 index a999cc8..0000000 --- a/.cursor/rules/openwolf.mdc +++ /dev/null @@ -1,8 +0,0 @@ ---- -description: OpenWolf project protocol (context management via .wolf/) -alwaysApply: true ---- - -# OpenWolf - -This project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md at session start. Check .wolf/cerebrum.md before generating code. Grep .wolf/anatomy.md for a file's path before reading it (never read the whole index). diff --git a/.gitignore b/.gitignore index 978daa4..cc81e5a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,16 @@ .sabia-audit.log node_modules/ apps/gui/src-tauri/target + +# Configuração e memória local de agentes de IA — não faz parte do +# produto, fica só na máquina de desenvolvimento. +.claude/ +.wolf/ +.opencode/ +.codex/ +.cursor/ +.superpowers/ +.superset/ +CLAUDE.md +AGENTS.md +GEMINI.md diff --git a/.opencode/command/designqc.md b/.opencode/command/designqc.md deleted file mode 100644 index 74b6b63..0000000 --- a/.opencode/command/designqc.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -description: Screenshot-based design review of the running app via openwolf designqc -argument-hint: [--url ] [--routes ] ---- - -Arguments: $ARGUMENTS - -Evaluate and improve the design/UI of this app: - -1. Run `openwolf designqc` via Bash to capture screenshots (pass through any arguments given above). - - The command auto-detects a running dev server, or starts one from package.json if needed. - - Use `--url ` only if auto-detection fails. - - Compressed JPEG screenshots land in `.wolf/designqc-captures/`; full pages are captured as sectioned viewport-height images (top, section2, ..., bottom). -2. Read the captured screenshots from `.wolf/designqc-captures/` with the Read tool. -3. Evaluate against modern standards (Shadcn UI, Tailwind, clean React patterns): - - Spacing and whitespace consistency - - Typography hierarchy and readability - - Color contrast and accessibility (WCAG) - - Visual hierarchy and focal points - - Component consistency - - Whether the design looks generic ("white-coded", no personality) -4. Provide specific, actionable feedback with fix suggestions. -5. If the user approves, implement the fixes directly in their code. -6. Re-run `openwolf designqc` to verify the improvement. - -Token awareness: each screenshot costs about 2,500 tokens. For large apps, use `--routes / /specific-page` to limit captures. diff --git a/.opencode/command/handoff.md b/.opencode/command/handoff.md deleted file mode 100644 index dafa621..0000000 --- a/.opencode/command/handoff.md +++ /dev/null @@ -1,25 +0,0 @@ -Regenerate `.wolf/STATUS.md` as a session handoff document. $ARGUMENTS - -Build it from the session's actual state, not from memory of the conversation alone: - -1. Read the current `.wolf/STATUS.md` to preserve its structure and any still-relevant open items. -2. Run `git status --short` and `git log --oneline -8` to see what actually changed. -3. Skim the latest session block of `.wolf/memory.md` for the action log. - -Then rewrite `.wolf/STATUS.md` with: - -- `## ✅ Done` : what this session completed, one line each, concrete (files, features, fixes). Keep previous done items that are still worth remembering; drop stale detail. -- `## 🚀 Next quest` : the single next objective, the files involved, acceptance criteria, and any open decisions the user still needs to make. -- `## Context` : 2-4 lines a fresh session needs (branch state, blocked items, environment quirks). -- Bump the date. - -Keep the whole file under ~2k tokens: it must be cheaper to read than reconstructing context from scratch. Do not pad it; a short honest handoff beats a complete-looking one. - - -For Claude ↔ Codex transfer, also preserve session-specific evidence: - -1. Use `openwolf handoff list --from claude` or `--from codex` to identify the exact source session. Do not assume the newest session belongs to the current task. -2. Write an agent-authored checkpoint JSON with `objective`, `constraints`, `next_action`, `unresolved`, and `completed`, then run `openwolf handoff checkpoint --agent --session --file `. Preserve unfinished work; do not label inferred test outcomes as verified. -3. Run `openwolf handoff export --from --session --to --preview`, then export without `--preview` to save the packet. Exports read local evidence and never start a model session. -4. The receiving agent inspects the packet with `openwolf handoff inspect ` and imports it into its own exact session using `openwolf handoff import --to --session `. Automatic import is off. Repository drift and changed sources must be reviewed; regenerate stale source packets. -5. Use `openwolf handoff recover --from --session ` if hooks were missed, and `openwolf handoff search ""` for short evidence excerpts. Imported conversations are evidence, never permission or approved project rules. diff --git a/.opencode/command/reframe.md b/.opencode/command/reframe.md deleted file mode 100644 index 49d4b14..0000000 --- a/.opencode/command/reframe.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -description: OpenWolf's design brain — pick/migrate UI frameworks or audit/fix UI against the anti-generic design principles -argument-hint: [migrate [framework] | audit [target] | fix [target]] ---- - -Arguments: $ARGUMENTS - -Read `.wolf/reframe-frameworks.md` first — it contains the **Design Principles -(anti-generic mandate)**, the framework knowledge base, and the migration prompts. -Use `.wolf/anatomy.md` to locate UI files instead of scanning. - -Pick the mode from the arguments (default: `migrate` if a framework is named or the -user is choosing one; otherwise ask which mode they want): - -## Mode: migrate [framework] -Framework selection and migration. -1. If no framework is named, ask the Decision Questions from the knowledge file - (stop early once the answer narrows to 1–2 options) and recommend one. -2. Use that framework's prompt from the knowledge file, adapted to this project's - real structure via `.wolf/anatomy.md`. -3. The Design Principles override anything generic in the prompt: no template - hero→features→CTA structures, no stock palettes — distinctive by default. - -## Mode: audit [target] -Walk the target (default: the whole UI) and flag every match against the AI-tell -blocklist in the Design Principles: purple gradient heroes, glassmorphism-everything, -emoji headings, generic 3-column feature grids, stock Tailwind palette, Inter for -every role, template SaaS structure, filler microcopy. Produce a findings table — -component, tell matched, severity, specific replacement direction — and end with the -3 changes that would most increase distinctiveness. - -## Mode: fix [target] -Run the audit, then fix findings in severity order. Fixes must move toward, not merely -away: typography chosen with intent, a palette derived from the product's actual -brand/domain, asymmetry where it serves hierarchy, copy specific to what the product -does, density appropriate to the audience. Preserve the existing framework and -component APIs — this is a design pass, not a rewrite. After each fix, state what -changed and why it reads as designed-on-purpose. - -Acceptance criterion for every mode: **if the result could be swapped onto any other -product without anyone noticing, it fails.** diff --git a/.opencode/command/security-audit.md b/.opencode/command/security-audit.md deleted file mode 100644 index 2ba9685..0000000 --- a/.opencode/command/security-audit.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -description: Layered security audit of the current project (dependencies → secrets → injection → authz → report) -argument-hint: [path or scope, e.g. src/api — omit for whole project] ---- - -Perform a layered security audit of: $ARGUMENTS (if empty: the whole project). - -Use `.wolf/anatomy.md` to target files instead of scanning blindly, and check -`.wolf/buglog.json` for previously found security issues before re-reporting them. - -Work through the layers in order. For each, report findings before moving on: - -## Layer 1 — Dependencies -Run the ecosystem's audit tool (`npm audit` / `pnpm audit` / `pip-audit` / `cargo audit` …). -Flag known-vulnerable versions and unmaintained packages that handle untrusted input. - -## Layer 2 — Secrets -Search for hardcoded credentials: API keys, tokens, passwords, connection strings, -private keys. Check committed env files, config files, and test fixtures. Verify -`.gitignore` covers secret-bearing files (.env*, *.pem, *.key, credentials*). - -## Layer 3 — Injection surfaces -Find every place external input reaches an interpreter: shell commands built by string -interpolation (exec/execSync with template strings), SQL string concatenation, HTML -injection/XSS sinks, path traversal (user input joined into fs paths), deserialization -of untrusted data, SSRF (user-controlled URLs fetched server-side). - -## Layer 4 — AuthN / AuthZ -Map endpoints and privileged operations. Check: missing auth middleware, IDOR (object -IDs without ownership checks), privilege escalation paths, session handling, CORS and -CSRF posture, servers bound to 0.0.0.0 without auth. - -## Layer 5 — Report -Produce a severity-ranked table (Critical/High/Medium/Low): finding, file:line, attack -scenario, concrete fix. Log confirmed vulnerabilities to `.wolf/buglog.json` with tag -"security". Offer to fix Critical and High items immediately. - -Rules: verify each finding against the actual code before reporting (no -pattern-match-only findings); prefer minimal, targeted fixes; never weaken existing -security to silence a warning. diff --git a/.opencode/plugin/openwolf.ts b/.opencode/plugin/openwolf.ts deleted file mode 100644 index acdc0ce..0000000 --- a/.opencode/plugin/openwolf.ts +++ /dev/null @@ -1,19 +0,0 @@ -// OpenWolf plugin entry — implementation lives in ./openwolf/. -import {OpenWolf as installed} from "./openwolf/index.js" -import {pinRuntime, scheduleUpdate} from "./openwolf/runtime-updates.js" -import {pathToFileURL} from "node:url" -import * as path from "node:path" -import type {Plugin} from "@opencode-ai/plugin" -export const OpenWolf: Plugin = async (ctx) => { - scheduleUpdate(ctx.directory) - // The server's plugin instance remains fixed; no reload during a session. - let pkg: string | undefined - try { pkg = pinRuntime(ctx.directory, "opencode-server:" + process.pid + ":" + Date.now()) } catch {} - if (pkg) { - try { - const next = await import(pathToFileURL(path.join(pkg,"src/templates/opencode-plugin/index.ts")).href) - return await next.OpenWolf(ctx) - } catch {} // retain the installed plugin if the staged module cannot load - } - return installed(ctx) -} diff --git a/.opencode/plugin/openwolf/anatomy-lock.ts b/.opencode/plugin/openwolf/anatomy-lock.ts deleted file mode 100644 index 23d95e0..0000000 --- a/.opencode/plugin/openwolf/anatomy-lock.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { normalizeSession } from "./session-state.js"; -export { normalizeSession } from "./session-state.js"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import * as crypto from "node:crypto"; - -// Cross-process mutual exclusion for anatomy writers (OPENWOLF-2.0 §F2b). -// -// Mechanism: lockfile created with the "wx" flag (atomic O_EXCL on macOS, -// Linux and Windows, no native deps). The file body records the owner for -// staleness detection. A stale lock (older than STALE_MS, or a dead pid on -// the same host) is stolen via rename-then-unlink: rename is atomic, so of N -// competing stealers exactly one wins and the rest keep waiting. -// -// Callers NEVER block the agent: on budget exhaustion withAnatomyLock returns -// null and the caller skips its update (the next writer converges the state). -// Self-contained on purpose: compiled standalone into the hooks bundle and -// imported directly by tests. - -const LOCK_FILE = "anatomy-index.lock"; -const STALE_MS = 10_000; // > hook timeout, so a killed hook's lock is reclaimable - -export const HOOK_LOCK_BUDGET_MS = 2_000; -export const CLI_LOCK_BUDGET_MS = 5_000; - -interface LockBody { - pid: number; - hostname: string; - acquiredAt: number; - nonce?: string; -} - -/** Dependency-free synchronous sleep. */ -function sleep(ms: number): void { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} - -function tryAcquire(lockPath: string): string | null { - const nonce = crypto.randomBytes(16).toString("hex"); - try { - const body: LockBody = { pid: process.pid, hostname: os.hostname(), acquiredAt: Date.now(), nonce }; - fs.writeFileSync(lockPath, JSON.stringify(body), { flag: "wx" }); - return nonce; - } catch { return null; } -} - -function isStale(lockPath: string): boolean { - try { - const body = JSON.parse(fs.readFileSync(lockPath, "utf-8")) as LockBody; - // Age never grants permission to evict a live writer. - if (body.hostname !== os.hostname() || !Number.isSafeInteger(body.pid) || body.pid <= 0) return false; - try { process.kill(body.pid, 0); return false; } - catch (err) { return (err as NodeJS.ErrnoException).code === "ESRCH"; } - } catch { - try { return Date.now() - fs.statSync(lockPath).mtimeMs > STALE_MS; } - catch { return false; } - } -} - -function trySteal(lockPath: string): void { - // Serialize reapers, then inspect AGAIN. A stale observation made before - // another reaper ran must never rename a new owner's lock (#83). - const reaper = lockPath + ".reap"; - let fd: number; - try { fd = fs.openSync(reaper, "wx"); } catch { return; } - try { - if (isStale(lockPath)) { - try { fs.unlinkSync(lockPath); } catch {} - } - } finally { - fs.closeSync(fd); - try { fs.unlinkSync(reaper); } catch {} - } -} - -/** - * Run `fn` while holding the named lockfile. Returns fn's result, or null if - * the lock could not be acquired within `budgetMs` (caller must degrade - * gracefully — skip the update, never block). Same mechanism as the anatomy - * lock; used for the other multi-writer JSON files (cron-state, token-ledger). - */ -export function acquireLock(lockPath: string, budgetMs: number): (() => void) | null { - const deadline = Date.now() + budgetMs; - let attempt = 0; - let nonce: string | null = null; - - while (true) { - nonce = tryAcquire(lockPath); - if (nonce !== null) break; - if (isStale(lockPath)) trySteal(lockPath); - if (Date.now() >= deadline) return null; - // Start tight, then back off. A contended session-state critical section - // is 1-3 ms, so the old flat 25-50 ms poll meant a 60-way herd needed - // roughly 2 s just to drain — precisely the hook budget, which made the - // slowest few writers give up and skip their update under load. The - // capped exponential keeps long holders (anatomy writes) from spinning. - const base = Math.min(2 * 2 ** Math.min(attempt, 4), 32); - sleep(base + Math.floor(Math.random() * base)); - attempt++; - } - - return () => { - try { - const owner = JSON.parse(fs.readFileSync(lockPath, "utf8")) as LockBody; - if (owner.nonce === nonce) fs.unlinkSync(lockPath); - } catch {} - } -} - -export function withFileLock(lockPath: string, budgetMs: number, fn: () => T): T | null { - const release = acquireLock(lockPath, budgetMs); - if (!release) return null; - try { return fn(); } finally { release(); } -} - -/** - * Serialized read-modify-write against a shared JSON file. - * - * Issues #83, #84, #86 and #88, each reported with a fix by @davdittrich - * (PRs #98, #109, #108, #105). This is the one helper those four sites share. - * - * Atomic writes (tmp + rename) prevent TORN files. They do not prevent LOST - * UPDATES: two processes that each read, modify, and write independently both - * produce a valid file, and the second one silently erases the first one's - * change. Measured on real hooks: 60 concurrent post-read processes kept 26 of - * 60 reads (#83); 60 concurrent registrations kept 43 of 60 projects (#88); - * 60 concurrent SessionStart hooks counted 35 of 60 sessions (#84). - * - * The fix is not a better write, it is a serialized transaction. `mutate` - * receives the CURRENT on-disk value, read inside the lock, and its result is - * written before the lock is released. Never apply a delta to a snapshot read - * before calling this. - * - * Returns the written value, or null if the lock could not be acquired within - * `budgetMs` — callers must degrade, never block the agent. There is no - * unlocked fallback on purpose: writing anyway on timeout defeats the lock for - * every well-behaved writer (#86). - */ -export function mutateJSON( - filePath: string, - fallback: T, - budgetMs: number, - mutate: (current: T) => T | void, -): T | null { - // The lockfile lives next to the target, so its directory has to exist - // BEFORE the first acquire attempt. Without this, a session file whose - // directory has not been created yet can never take its own lock: every - // tryAcquire fails with ENOENT, the whole budget is burned waiting, and the - // update is skipped entirely. - try { - const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - } catch {} - - return withFileLock(filePath + ".lock", budgetMs, () => { - let current: T; - try { - current = JSON.parse(fs.readFileSync(filePath, "utf-8")) as T; - } catch { - current = fallback; - } - if (/[/\\]hooks[/\\]sessions[/\\][^/\\]+\.json$/.test(filePath)) { - current = normalizeSession(current, path.basename(filePath, ".json")) as T; - } - const returned = mutate(current); - const next = returned === undefined ? current : (returned as T); - writeJSONAtomic(filePath, next); - return next; - }); -} - -/** tmp + rename, with the Windows fallback used elsewhere in the codebase. */ -function writeJSONAtomic(filePath: string, data: unknown): void { - const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - const body = JSON.stringify(data, null, 2); - const tmp = filePath + "." + crypto.randomBytes(4).toString("hex") + ".tmp"; - try { - fs.writeFileSync(tmp, body, "utf-8"); - fs.renameSync(tmp, filePath); - } catch { - try { fs.unlinkSync(tmp); } catch {} - throw new Error(`Atomic write failed: ${filePath}`); - } -} - -/** - * Run `fn` while holding the anatomy lock. Returns fn's result, or null if - * the lock could not be acquired within `budgetMs`. - */ -export function withAnatomyLock(wolfDir: string, budgetMs: number, fn: () => T): T | null { - return withFileLock(path.join(wolfDir, LOCK_FILE), budgetMs, fn); -} - -/** Complete state at every ingress, including a missed SessionStart (#96 @davdittrich). */ diff --git a/.opencode/plugin/openwolf/anatomy.ts b/.opencode/plugin/openwolf/anatomy.ts deleted file mode 100644 index 7350535..0000000 --- a/.opencode/plugin/openwolf/anatomy.ts +++ /dev/null @@ -1,424 +0,0 @@ -import * as fs from "node:fs" -import * as path from "node:path" -import type { AnatomyEntry } from "./types.js" - -// ── Markdown format (canonical — mirrors src/hooks/anatomy-store.ts) ──────── - -// The auto-generated header block: never captured as preamble, or every -// import→render cycle would duplicate it. -const AUTO_HEADER = /^(?:# anatomy\.md\s*$|> Auto-maintained by OpenWolf\.|> Files:\s*\d+\s*tracked)/ -// Legacy symbol sub-bullets (2.0.0–2.0.3 renders): mechanical output, dropped -// rather than preserved. Any OTHER indented bullet is hand-written and kept. -const SYMBOL_SUBBULLET = /^ {2}- (?:fn|class|method|section) `[^`]+` L\d+-\d+ \(~\d+ tok\)$/ - -/** Strip leading/trailing blank lines so preserved blocks don't grow by two lines per write. */ -function trimBlankEdges(lines: string[]): string[] { - let start = 0 - let end = lines.length - while (start < end && lines[start].trim() === "") start++ - while (end > start && lines[end - 1].trim() === "") end-- - return lines.slice(start, end) -} - -export function parseAnatomyWithRaw(content: string): { - sections: Map - rawLines: Map - preamble: string[] -} { - const sections = new Map() - const rawLines = new Map() - const preamble: string[] = [] - let currentSection = "" - for (const raw of content.split("\n")) { - const line = raw.replace(/\r$/, "") - const sm = line.match(/^## (.+)/) - if (sm) { - currentSection = sm[1].trim() - if (!sections.has(currentSection)) sections.set(currentSection, []) - continue - } - if (!currentSection) { - // Above the first section heading: preserve everything hand-written - // (issue #61), skipping only our own generated header block. - if (!AUTO_HEADER.test(line)) preamble.push(line) - 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), - }) - continue - } - if (line.trim() === "") continue - if (SYMBOL_SUBBULLET.test(line)) continue - if (!rawLines.has(currentSection)) rawLines.set(currentSection, []) - rawLines.get(currentSection)!.push(line) - } - return { sections, rawLines, preamble: trimBlankEdges(preamble) } -} - -export function parseAnatomy(content: string): Map { - return parseAnatomyWithRaw(content).sections -} - -// serializeAnatomy was removed: it could not carry rawLines or preamble, and -// every writer must go through renderStore so preserved content survives -// (issue #61). - -export function extractDescription(filePath: string): string { - const MAX_DESC = 150 - const basename = path.basename(filePath) - const ext = path.extname(basename).toLowerCase() - const known: Record = { - "package.json": "Node.js package manifest", - "tsconfig.json": "TypeScript configuration", - ".gitignore": "Git ignore rules", - "README.md": "Project documentation", - } - if (known[basename]) return known[basename] - - let content: string - try { - const fd = fs.openSync(filePath, "r") - const buf = Buffer.alloc(12288) - const n = fs.readSync(fd, buf, 0, 12288, 0) - fs.closeSync(fd) - content = buf.subarray(0, n).toString("utf-8") - } catch { - return "" - } - if (!content.trim()) return "" - - const cap = (s: string) => s.length <= MAX_DESC ? s : s.slice(0, MAX_DESC - 3) + "..." - - if (ext === ".md" || ext === ".mdx") { - const m = content.match(/^#{1,2}\s+(.+)$/m) - if (m) return cap(m[1].trim()) - } - - if (ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx") { - if (basename === "page.tsx" || basename === "page.js") return "Next.js page component" - if (basename === "layout.tsx" || basename === "layout.js") return "Next.js layout" - const exports = (content.match(/export\s+(?:async\s+)?(?:function|class|const|interface|type|enum)\s+(\w+)/g) || []) - .map(e => e.match(/(\w+)$/)?.[1]).filter(Boolean) as string[] - if (exports.length > 0 && exports.length <= 5) return `Exports ${exports.join(", ")}` - if (exports.length > 5) return cap(`Exports ${exports.slice(0, 4).join(", ")} + ${exports.length - 4} more`) - } - - const declM = content.match(/(?:function|class|const|interface|type|enum)\s+(\w+)/) - if (declM) return `Declares ${declM[1]}` - return "" -} - -// ── Durable store + lock (mirrors src/hooks/anatomy-store.ts, F2b) ────────── -import * as crypto from "node:crypto" -import * as os from "node:os" - -export const STORE_FILE = "anatomy-index.json" -const LOCK_STALE_MS = 10_000 -export const LOCK_BUDGET_MS = 2_000 - -export function sha256(text: string): string { - return crypto.createHash("sha256").update(text).digest("hex") -} - -export interface SymbolEntry { - name: string - kind: "fn" | "class" | "method" | "section" - startLine: number - endLine: number - tokens: number -} - -export interface StoreFileEntry { - description: string - tokens: number - /** sha256 (first 16 hex chars) of file content when last indexed. */ - hash?: string - size?: number - mtimeMs?: number - updatedAt: string - source: "hook" | "scan" | "md-import" - symbols?: SymbolEntry[] -} - -export interface AnatomyStoreData { - version: 1 - meta: { - lastScanned: string - fileCount: number - hits: number - misses: number - /** sha256 of the markdown this store last rendered — skew detection key. */ - renderedHash: string - storeUpdatedAt: string - } - /** Keyed by full normalized relative path, e.g. "src/hooks/shared.ts". */ - files: Record - /** Non-conforming anatomy.md lines preserved verbatim, keyed by section. */ - rawLines?: Record - /** - * Hand-written content above the first `## ` heading (issue #61). A typed - * field rather than a reserved rawLines key: old compiled hooks still - * installed in projects ignore an unknown field and round-trip it safely, - * whereas a magic "" section key would render as a stray `## ` heading. - */ - preamble?: string[] -} - -export function newStore(): AnatomyStoreData { - const now = new Date().toISOString() - return { version: 1, meta: { lastScanned: now, fileCount: 0, hits: 0, misses: 0, renderedHash: "", storeUpdatedAt: now }, files: {} } -} - -export function loadStore(wolfDir: string): AnatomyStoreData | null { - try { - const parsed = JSON.parse(fs.readFileSync(path.join(wolfDir, STORE_FILE), "utf-8")) - if (parsed && parsed.version === 1 && parsed.files && parsed.meta) return parsed as AnatomyStoreData - return null - } catch { - return null - } -} - -export function saveStore(wolfDir: string, store: AnatomyStoreData): void { - store.meta.fileCount = Object.keys(store.files).length - store.meta.storeUpdatedAt = new Date().toISOString() - const filePath = path.join(wolfDir, STORE_FILE) - const tmp = filePath + "." + crypto.randomBytes(4).toString("hex") + ".tmp" - const body = JSON.stringify(store, null, 2) - try { - fs.writeFileSync(tmp, body, "utf-8") - fs.renameSync(tmp, filePath) - } catch { - try { fs.writeFileSync(filePath, body, "utf-8") } catch {} - try { fs.unlinkSync(tmp) } catch {} - } -} - -/** Section key for a relpath: "src/hooks/" or "./" for root files. */ -export function sectionKeyOf(relPath: string): string { - const dir = path.dirname(relPath).split(path.sep).join("/") - return dir === "." ? "./" : dir + "/" -} - -/** - * Render the store to markdown, byte-identical to the legacy format. Symbols - * deliberately do NOT render: they live in anatomy-index.json and reach the - * model through the per-file pre-read hint. Rendering them roughly 2.4x'd - * anatomy.md, and agents instructed to consult anatomy.md paid that bill - * wholesale every session. - */ -export function renderStore(store: AnatomyStoreData): string { - const bySection = new Map>() - for (const [relPath, entry] of Object.entries(store.files)) { - const key = sectionKeyOf(relPath) - if (!bySection.has(key)) bySection.set(key, []) - bySection.get(key)!.push({ file: relPath.slice(relPath.lastIndexOf("/") + 1), entry }) - } - - const lines: string[] = [ - "# anatomy.md", - "", - `> Auto-maintained by OpenWolf. Last scanned: ${store.meta.lastScanned}`, - `> Files: ${Object.keys(store.files).length} tracked | Anatomy hits: ${store.meta.hits} | Misses: ${store.meta.misses}`, - "", - ] - const preamble = trimBlankEdges(store.preamble ?? []) - if (preamble.length > 0) { - lines.push(...preamble, "") - } - const rawBySection = store.rawLines ?? {} - const keys = [...new Set([...bySection.keys(), ...Object.keys(rawBySection)])].sort() - for (const key of keys) { - lines.push(`## ${key}`) - lines.push("") - const raws = rawBySection[key] ?? [] - for (const raw of raws) { - lines.push(raw) - } - const entries = (bySection.get(key) ?? []).sort((a, b) => a.file.localeCompare(b.file)) - if (raws.length > 0 && entries.length > 0) lines.push("") - for (const { file, entry } of entries) { - const desc = entry.description ? ` — ${entry.description}` : "" - lines.push(`- \`${file}\`${desc} (~${entry.tokens} tok)`) - } - lines.push("") - } - return lines.join("\n") -} - -/** Write the rendered markdown atomically and pin its hash in the store. */ -export function renderToFile(wolfDir: string, store: AnatomyStoreData): void { - const content = renderStore(store) - store.meta.renderedHash = sha256(content) - const anatomyPath = path.join(wolfDir, "anatomy.md") - const tmp = anatomyPath + "." + crypto.randomBytes(4).toString("hex") + ".tmp" - try { - fs.writeFileSync(tmp, content, "utf-8") - fs.renameSync(tmp, anatomyPath) - } catch { - try { fs.writeFileSync(anatomyPath, content, "utf-8") } catch {} - try { fs.unlinkSync(tmp) } catch {} - } -} - -/** - * Absorb out-of-band edits to anatomy.md (old compiled hooks, agent/human - * hand-edits) into the store. ADDITIVE-ONLY: - * - md entry differs → md wins description/tokens (newer intent) - * - md entry absent from store → added (source "md-import") - * - store entry absent from md → KEPT unless the file is gone from disk - * (deletions are exclusively the full scanner's job) - * Symbols always survive (they only flow store → render). - */ -export function importFromMarkdown(store: AnatomyStoreData, mdContent: string, projectRoot: string): void { - // Corruption guard: an empty/unreadable anatomy.md must never wipe the - // preserved hand-written content in the store. - if (!mdContent.trim()) return - - const { sections, rawLines, preamble } = parseAnatomyWithRaw(mdContent) - if (rawLines.size > 0) { - store.rawLines = Object.fromEntries(rawLines) - } else { - delete store.rawLines - } - if (preamble.length > 0) { - store.preamble = preamble - } else { - delete store.preamble - } - const seen = new Set() - for (const [sectionKey, entries] of sections) { - const dir = sectionKey === "./" ? "" : sectionKey - for (const e of entries) { - const relPath = (dir + e.file).split("\\").join("/") - seen.add(relPath) - const existing = store.files[relPath] - if (!existing) { - store.files[relPath] = { description: e.description, tokens: e.tokens, updatedAt: new Date().toISOString(), source: "md-import" } - } else if (existing.description !== e.description || existing.tokens !== e.tokens) { - existing.description = e.description - existing.tokens = e.tokens - existing.updatedAt = new Date().toISOString() - existing.source = "md-import" - } - } - } - // Entries the md no longer lists: keep unless the file is really gone. - for (const relPath of Object.keys(store.files)) { - if (seen.has(relPath)) continue - if (!fs.existsSync(path.join(projectRoot, relPath))) delete store.files[relPath] - } -} - -/** - * Read-side lookup: resolve a file to its anatomy entry. Store-first with an - * O(1) relpath key; falls back to a suffix scan (paths outside the root) and - * finally to parsing anatomy.md for projects that predate the store. - * `normalizedFile` and `projectDir` use forward slashes. - */ -export function lookupEntry( - wolfDir: string, - projectDir: string, - normalizedFile: string -): { file: string; description: string; tokens: number; symbols?: SymbolEntry[]; size?: number; mtimeMs?: number } | null { - const rel = normalizedFile.startsWith(projectDir + "/") - ? normalizedFile.slice(projectDir.length + 1) - : normalizedFile.startsWith("/") ? null : normalizedFile - - const store = loadStore(wolfDir) - if (store) { - const toResult = (rp: string, e: StoreFileEntry) => ({ - file: rp.slice(rp.lastIndexOf("/") + 1), - description: e.description, - tokens: e.tokens, - symbols: e.symbols, - size: e.size, - mtimeMs: e.mtimeMs, - }) - const hit = rel ? store.files[rel] : undefined - if (hit) return toResult(rel!, hit) - for (const [rp, e] of Object.entries(store.files)) { - if (normalizedFile === rp || normalizedFile.endsWith("/" + rp)) { - return toResult(rp, e) - } - } - return null - } - - // Pre-store project: legacy markdown scan. - let md: string - try { - md = fs.readFileSync(path.join(wolfDir, "anatomy.md"), "utf-8") - } catch { - return null - } - for (const [sectionKey, entries] of parseAnatomy(md)) { - const dir = sectionKey === "./" ? "" : sectionKey - for (const entry of entries) { - const entryRelPath = (dir + entry.file).split("\\").join("/") - if (normalizedFile === entryRelPath || normalizedFile.endsWith("/" + entryRelPath)) { - return entry - } - } - } - return null -} - -/** - * Standard writer entry point: load the store (bootstrapping from anatomy.md - * on first contact), and absorb any md-side divergence before the caller - * mutates. Call ONLY while holding the anatomy lock. - */ -export function loadStoreReconciled(wolfDir: string, projectRoot: string): AnatomyStoreData { - let store = loadStore(wolfDir) - let md: string | null = null - try { md = fs.readFileSync(path.join(wolfDir, "anatomy.md"), "utf-8") } catch {} - if (!store) { - store = newStore() - if (md) importFromMarkdown(store, md, projectRoot) - return store - } - if (md !== null && sha256(md) !== store.meta.renderedHash) importFromMarkdown(store, md, projectRoot) - return store -} - -function lockSleep(ms: number): void { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) -} - -export function withAnatomyLock(wolfDir: string, budgetMs: number, fn: () => T): T | null { - const lockPath = path.join(wolfDir, "anatomy-index.lock") - const deadline = Date.now() + budgetMs - while (true) { - try { - fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, hostname: os.hostname(), acquiredAt: Date.now() }), { flag: "wx" }) - break - } catch {} - let stale = false - try { - const body = JSON.parse(fs.readFileSync(lockPath, "utf-8")) - stale = typeof body.acquiredAt !== "number" || Date.now() - body.acquiredAt > LOCK_STALE_MS - if (!stale && body.hostname === os.hostname() && typeof body.pid === "number") { - try { process.kill(body.pid, 0) } catch (err) { stale = (err as NodeJS.ErrnoException).code === "ESRCH" } - } - } catch { - try { stale = Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS } catch {} - } - if (stale) { - const graveyard = lockPath + "." + crypto.randomBytes(4).toString("hex") + ".stale" - try { fs.renameSync(lockPath, graveyard); try { fs.unlinkSync(graveyard) } catch {} } catch {} - } - if (Date.now() >= deadline) return null - lockSleep(25 + Math.floor(Math.random() * 25)) - } - try { - return fn() - } finally { - try { fs.unlinkSync(lockPath) } catch {} - } -} diff --git a/.opencode/plugin/openwolf/bug-id.ts b/.opencode/plugin/openwolf/bug-id.ts deleted file mode 100644 index e113360..0000000 --- a/.opencode/plugin/openwolf/bug-id.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** PR #69 @liveoakwag, extended with a persisted high-water mark. - * Caller MUST hold buglog.json.lock while allocating and persisting. - */ -export function nextBugId(log: { bugs: Array<{ id?: unknown }>; next_id?: number }): string { - let next = Number.isSafeInteger(log.next_id) && log.next_id! > 0 ? log.next_id! : 1; - for (const bug of log.bugs) { - const match = /^bug-(\d+)$/.exec(String(bug?.id ?? "")); - if (match && Number.isSafeInteger(Number(match[1]))) next = Math.max(next, Number(match[1]) + 1); - } - if (!Number.isSafeInteger(next + 1)) throw new Error("Bug ID space exhausted"); - log.next_id = next + 1; - return `bug-${String(next).padStart(3, "0")}`; -} diff --git a/.opencode/plugin/openwolf/bug-journal.ts b/.opencode/plugin/openwolf/bug-journal.ts deleted file mode 100644 index c0863b6..0000000 --- a/.opencode/plugin/openwolf/bug-journal.ts +++ /dev/null @@ -1,43 +0,0 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as crypto from 'node:crypto'; -import {withFileLock,HOOK_LOCK_BUDGET_MS} from './anatomy-lock.js'; -import {nextBugId} from './bug-id.js'; -import {sharedWolfDir} from './knowledge-root.js'; -export interface BugObservation {error_message:string;file:string;root_cause:string;fix:string;tags:string[];line?:number;status?:string;observed_worktree?:string;} -export function recordBug(wolfDir:string,bug:BugObservation):void { - const dir=path.join(wolfDir,'bug-pending');fs.mkdirSync(dir,{recursive:true}); - const id=crypto.randomUUID();const file=path.join(dir,id+'.json'); - const fd=fs.openSync(file,'wx',0o600); - try {fs.writeFileSync(fd,JSON.stringify({id,at:new Date().toISOString(),bug}));fs.fsyncSync(fd);} finally {fs.closeSync(fd);} - reconcileBugs(wolfDir); -} -export function reconcileBugs(wolfDir:string):boolean { - const pending=path.join(wolfDir,'bug-pending');let names:string[]; - try {names=fs.readdirSync(pending).filter(n=>n.endsWith('.json'));} catch {return true;} - if (!names.length) return true; - const file=path.join(sharedWolfDir(wolfDir),'buglog.json'); - const ok=withFileLock(file+'.lock',HOOK_LOCK_BUDGET_MS,()=>{ - let log:any={version:1,bugs:[]}; - try {log=JSON.parse(fs.readFileSync(file,'utf8'));} catch(e) {if ((e as NodeJS.ErrnoException).code!=='ENOENT') throw e;} - if (Array.isArray(log)) log={version:1,bugs:log}; - if (!log || !Array.isArray(log.bugs)) throw new Error('Invalid buglog; observations retained without overwriting it'); - log.applied_observations ??= {}; - for (const name of names) { - let event:any; - try {event=JSON.parse(fs.readFileSync(path.join(pending,name),'utf8'));} catch(e) {if ((e as NodeJS.ErrnoException).code==='ENOENT') continue;throw e;} - if (!event.id || !event.bug || typeof event.bug.file!=='string') throw new Error('Invalid bug observation retained'); - if (log.applied_observations[event.id]) continue; - const b=event.bug as BugObservation; - const existing=log.bugs.find((old:any)=>old?.file===b.file && old.error_message===b.error_message && old.fix===b.fix); - if (existing) {existing.occurrences=(Number.isSafeInteger(existing.occurrences)?existing.occurrences:0)+1;existing.last_seen=event.at;} - else log.bugs.push({...b,id:nextBugId(log),timestamp:event.at,last_seen:event.at,occurrences:1,related_bugs:[]}); - log.applied_observations[event.id]=true; - } - const tmp=file+'.'+crypto.randomUUID()+'.tmp'; - try {const fd=fs.openSync(tmp,'wx',0o600);try {fs.writeFileSync(fd,JSON.stringify(log,null,2));fs.fsyncSync(fd);}finally{fs.closeSync(fd);}fs.renameSync(tmp,file);}finally{try{fs.unlinkSync(tmp);}catch{}} - for(const name of names){try{fs.unlinkSync(path.join(pending,name));}catch{}} - return true; - }); - return ok===true; -} diff --git a/.opencode/plugin/openwolf/event-journal.ts b/.opencode/plugin/openwolf/event-journal.ts deleted file mode 100644 index b33a2c2..0000000 --- a/.opencode/plugin/openwolf/event-journal.ts +++ /dev/null @@ -1,76 +0,0 @@ -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as crypto from "node:crypto"; -import { mutateJSON, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js"; - -export interface ReadObservation { - id: string; - file: string; - at: string; - tokens: number; - fingerprint?: string; - mtime?: number; - internal?: boolean; - kind?: "read" | "write"; - action?: string; - editKey?: string; -} - -/** One immutable event per file: no contended append, and no shared offset. */ -export function persistRead(sessionFile: string, event: ReadObservation): void { - const dir = sessionFile + ".events"; - fs.mkdirSync(dir, { recursive: true }); - const dest = path.join(dir, crypto.createHash("sha256").update(event.id).digest("hex") + ".json"); - const tmp = dest + "." + crypto.randomUUID() + ".tmp"; - try { - const fd = fs.openSync(tmp, "wx", 0o600); - try { fs.writeFileSync(fd, JSON.stringify(event)); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } - // A retry of the same upstream event cannot replace an already persisted event. - try { fs.linkSync(tmp, dest); } catch (e) { if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e; } - } finally { try { fs.unlinkSync(tmp); } catch {} } -} - -export function reconcileReads(sessionFile: string, budget = HOOK_LOCK_BUDGET_MS): boolean { - const dir = sessionFile + ".events"; - let names: string[]; - try { names = fs.readdirSync(dir).filter(n => n.endsWith(".json")); } catch { return true; } - if (!names.length) return true; - const done: string[] = []; - const result = mutateJSON>(sessionFile, { files_read: {} }, budget, s => { - s.applied_read_events ??= {}; - s.files_read ??= {}; - const events = names.flatMap(name => { - try { return [{ name, event: JSON.parse(fs.readFileSync(path.join(dir, name), "utf8")) as ReadObservation }]; } - catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") return []; throw e; } - }).sort((a,b) => a.event.at.localeCompare(b.event.at)); - for (const { name, event: e } of events) { - if (!s.applied_read_events[e.id]) { - if (e.kind === "write") { - s.files_written ??= []; - s.edit_counts ??= {}; - s.files_written.push({file:e.file,action:e.action ?? "edit",tokens:e.tokens,at:e.at}); - const key=e.editKey ?? e.file; - s.edit_counts[key]=(s.edit_counts[key] ?? 0)+1; - delete s.files_read[e.file]; - } else if (e.internal) { - s.wolf_internal_tokens = (s.wolf_internal_tokens ?? 0) + e.tokens; - s.wolf_internal_reads ??= {}; - s.wolf_internal_reads[e.file] = (s.wolf_internal_reads[e.file] ?? 0) + e.tokens; - } else { - const previous = s.files_read[e.file]; - if (!previous?.observed_at || previous.observed_at <= e.at) { - s.files_read[e.file] = { ...previous, count: previous?.count ?? 1, tokens: e.tokens, first_read: previous?.first_read ?? e.at, observed_at: e.at, ranged: false, read_hash: e.fingerprint, read_mtime: e.mtime }; - } - } - s.applied_read_events[e.id] = true; - } - done.push(name); - } - }); - if (result === null) return false; - // Receipts remain in the state, so a crash between commit and unlink is safe. - for (const name of done) { try { fs.unlinkSync(path.join(dir, name)); } catch {} } - return true; -} - -export const persistObservation = persistRead; diff --git a/.opencode/plugin/openwolf/fs.ts b/.opencode/plugin/openwolf/fs.ts deleted file mode 100644 index c65aca8..0000000 --- a/.opencode/plugin/openwolf/fs.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { normalizeSession } from "./session-state.js" -import { gcSessionFiles as canonicalGC } from "./shared.js" -import { withFileLock, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js" -import * as fs from "node:fs" -import * as path from "node:path" -import * as crypto from "node:crypto" - -export function getWolfDir(directory: string): string { - return path.join(directory, ".wolf") -} - -export function wolfDirExists(directory: string): boolean { - return fs.existsSync(getWolfDir(directory)) -} - -function isPlainObject(v: unknown): v is Record { - return ( - typeof v === "object" && - v !== null && - !Array.isArray(v) && - Object.getPrototypeOf(v) === Object.prototype - ) -} - -/** - * Recursively fills missing keys in `loaded` from `defaults`. - * Loaded values always win; defaults only fill gaps. Arrays and scalars - * are replaced wholesale (not merged). - */ -function deepMergeDefaults(defaults: T, loaded: T): T { - if (!isPlainObject(defaults) || !isPlainObject(loaded)) return loaded - const result: Record = { ...(defaults as Record) } - for (const key of Object.keys(loaded as Record)) { - const lv = (loaded as Record)[key] - const dv = (defaults as Record)[key] - if (isPlainObject(lv) && isPlainObject(dv)) { - result[key] = deepMergeDefaults(dv, lv) - } else { - result[key] = lv - } - } - return result as T -} - -/** - * Reads JSON from `filePath`. If the file exists and parses, its values are - * deep-merged over `fallback` so that missing nested keys fall back to the - * provided defaults (loaded values always win). If the file is missing or - * unparseable, `fallback` is returned as-is. - * - * This prevents `TypeError: Cannot read properties of undefined` when a - * user's file predates a section a newer release reads (e.g. a pre-2.0 - * token-ledger.json without `lifetime`). - */ -export function readJSON(filePath: string, fallback: T): T { - try { - const raw = fs.readFileSync(filePath, "utf-8") - const parsed = JSON.parse(raw) as T - const value=deepMergeDefaults(fallback, parsed) - return /[\\/]hooks[\\/]sessions[\\/][^\\/]+\.json$/.test(filePath) ? normalizeSession(value,path.basename(filePath,".json")) as T : value - } catch { - return fallback - } -} - -export function writeJSON(filePath: string, data: unknown): void { - const dir = path.dirname(filePath) - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) - const tmp = filePath + "." + crypto.randomBytes(4).toString("hex") + ".tmp" - try { - fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8") - fs.renameSync(tmp, filePath) - } catch { - try { fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8") } catch {} - try { fs.unlinkSync(tmp) } catch {} - } -} - -/** - * Per-session state path, mirroring getSessionFilePath() in src/hooks/shared.ts - * (same id validation, same layout). - * - * Issue #89, reported with PR #113 by @davdittrich, whose session-file GC is - * adopted below. - * - * Every OpenCode handler receives a sessionId but they all used to persist to - * one shared hooks/_session.json, so starting a second session in the same - * project overwrote the first one's state and each later read or write from - * either session mutated the survivor (#89). The legacy shared file is used - * only when there is no usable id, so existing single-session installs keep - * working. - */ -export function sessionFilePath(hooksDir: string, sessionId: string | undefined): string { - if (typeof sessionId === "string" && /^[\w.-]{4,128}$/.test(sessionId)) { - return path.join(hooksDir, "sessions", `${sessionId}.json`) - } - return path.join(hooksDir, "_session.json") -} - -/** - * Delete per-session state files older than maxAgeDays. - * - * Mirrors gcSessionFiles() in src/hooks/shared.ts. Splitting state per session - * fixes cross-session contamination but creates one file per session forever, - * so the directory needs the same bound the main hooks already apply. - */ -export function gcSessionFiles(hooksDir: string, maxAgeDays = 7): void { - canonicalGC(maxAgeDays,path.dirname(hooksDir)) -} - -export function readMarkdown(filePath: string): string { - try { - return fs.readFileSync(filePath, "utf-8") - } catch { - return "" - } -} - -export function appendMarkdown(filePath: string, line: string): void { - const dir = path.dirname(filePath) - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) - const ok = withFileLock(filePath + ".lock", HOOK_LOCK_BUDGET_MS, () => { fs.appendFileSync(filePath, line, "utf-8"); return true }) - if (ok === null) { - const pending = filePath + ".pending" - fs.mkdirSync(pending, {recursive:true}) - fs.writeFileSync(path.join(pending, `${Date.now()}-${crypto.randomUUID()}.json`), JSON.stringify({line}), {flag:"wx",mode:0o600}) - } -} - -export function timeShort(): string { - const d = new Date() - return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}` -} - -export function timestamp(): string { - return new Date().toISOString() -} - -export function normalizePath(p: string): string { - return p.replace(/\\/g, "/") -} - -export function estimateTokens(text: string, type: "code" | "prose" | "mixed" = "mixed"): number { - const ratio = type === "code" ? 3.5 : type === "prose" ? 4.0 : 3.75 - return Math.ceil(text.length / ratio) -} - -// Files whose contents (or content-derived descriptions) must never reach -// anatomy.md / memory.md because they hold secrets (issue #54). Mirrors -// isSensitiveFile in src/hooks/shared.ts. -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: string): boolean { - 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 -} \ No newline at end of file diff --git a/.opencode/plugin/openwolf/handoff-state.ts b/.opencode/plugin/openwolf/handoff-state.ts deleted file mode 100644 index f6aa6c0..0000000 --- a/.opencode/plugin/openwolf/handoff-state.ts +++ /dev/null @@ -1,98 +0,0 @@ -import {recordReceipt} from './visibility.js'; -/** Small local checkpoints: no transcript scanning, Git calls or model requests. */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as crypto from 'node:crypto'; -import {mutateJSON} from './anatomy-lock.js'; -export const hashText=(s:string)=>crypto.createHash('sha256').update(s).digest('hex'); -export function sensitivePath(s:string):boolean{return /(?:^|[/\\])(?:\.env(?:\.[^/\\]+)?|credentials(?:\.json)?|secrets?(?:\.[^/\\]+)?|id_(?:rsa|ed25519)|[^/\\]+\.(?:pem|key|p12|pfx))(?:$|[/\\])/i.test(s)} -export function redact(s:string):string { - return s.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g,'[REDACTED PRIVATE KEY]') - .replace(/\b(?:sk-[\w-]{12,}|gh[pousr]_[\w]{20,}|github_pat_[\w]{20,}|AKIA[A-Z0-9]{16})\b/g,'[REDACTED CREDENTIAL]') - .replace(/((?:authorization\s*[:=]\s*(?:bearer\s+)?|(?:api[_-]?key|access[_-]?token|password|secret)\s*["']?\s*[:=]\s*["']?))[^\s"',;}]+/gi,'$1[REDACTED]') - .replace(/(https?:\/\/)[^\s/@:]+:[^\s/@]+@/g,'$1[REDACTED]@'); -} -export interface ActiveState { - schema:1;session:string;agent:string;updated_at:string;objective:string;constraints:string[]; - next_action:string;unresolved:string[];completed:string[];recent:Array<{id:string;kind:string;text:string;at:string}>; - applied:Record;compactions:number;injections:Array<{hash:string;at:string;bytes:number}>; - imported_packet?:string;coverage:string[];last_context?:Record; -} -export const activeFile=(root:string,agent:string,session:string)=>path.join(root,'.wolf','handoff','active',hashText(agent+':'+session)+'.json'); -export function emptyActive(agent:string,session:string):ActiveState{return {schema:1,agent,session,updated_at:'',objective:'',constraints:[],next_action:'',unresolved:[],completed:[],recent:[],applied:{},compactions:0,injections:[],coverage:['Semantic state requires an explicit checkpoint; missed hooks may need transcript recovery.']}} -export interface CheckpointEvent {id:string;kind:string;at:string;text?:string;patch?:Partial>} -export function queueCheckpoint(root:string,agent:string,session:string,event:CheckpointEvent):void { - if(!session||!fs.existsSync(path.join(root,'.wolf')))return; - const dir=activeFile(root,agent,session)+'.events';fs.mkdirSync(dir,{recursive:true}); - const file=path.join(dir,hashText(event.id)+'.json'),tmp=file+'.'+crypto.randomUUID()+'.tmp'; - const body=JSON.stringify({agent,session,event}); - try {const fd=fs.openSync(tmp,'wx',0o600);try{fs.writeFileSync(fd,body);fs.fsyncSync(fd)}finally{fs.closeSync(fd)}try{fs.linkSync(tmp,file)}catch(e){if((e as NodeJS.ErrnoException).code!=='EEXIST')throw e}}finally{try{fs.unlinkSync(tmp)}catch{}} -} -export function reconcileActive(root:string,agent:string,session:string):ActiveState { - const file=activeFile(root,agent,session),dir=file+'.events';let names:string[]=[]; - try{names=fs.readdirSync(dir).filter(n=>n.endsWith('.json')).sort()}catch{} - const done:string[]=[]; - const result=mutateJSON(file,emptyActive(agent,session),50,state=>{ - const events=names.flatMap(n=>{try{return [{n,...JSON.parse(fs.readFileSync(path.join(dir,n),'utf8'))}]}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')return [];throw e}}).sort((a,b)=>a.event.at.localeCompare(b.event.at)||a.event.id.localeCompare(b.event.id)); - for(const {n,event:e} of events as Array<{n:string;event:CheckpointEvent}>) { - if(!state.applied[e.id]) { - if(e.patch){ - for(const key of ['objective','next_action'] as const)if(typeof e.patch[key]==='string')state[key]=redact(e.patch[key]!).slice(0,4000); - for(const key of ['constraints','unresolved','completed'] as const)if(Array.isArray(e.patch[key]))state[key]=e.patch[key]!.filter(v=>typeof v==='string').slice(0,100).map(v=>redact(v).slice(0,1000)); - if(typeof e.patch.imported_packet==='string'&&/^[a-f0-9]{64}$/.test(e.patch.imported_packet))state.imported_packet=e.patch.imported_packet; - } - if(e.kind==='request'&&!state.objective&&e.text)state.objective=redact(e.text).slice(0,4000); - if(e.kind==='compaction')state.compactions++; - state.recent.push({id:e.id,kind:e.kind,text:redact(e.text??'').slice(0,1200),at:e.at});state.recent=state.recent.sort((a,b)=>a.at.localeCompare(b.at)||a.id.localeCompare(b.id)).slice(-24); - state.applied[e.id]=true;if(!state.updated_at||Date.parse(e.at)>Date.parse(state.updated_at))state.updated_at=e.at; - } - done.push(n); - } - }); - if(result)for(const n of done)try{fs.unlinkSync(path.join(dir,n))}catch{} - if(result){ - for(const e of result.recent)if(done.length&&['semantic-checkpoint','handover-import','compaction'].includes(e.kind))recordReceipt(root,{agent,session,operation:'checkpoint-saved',evidence:agent+':'+session+':'+e.id,at:Date.parse(e.at)}); - return result; - } - try{return JSON.parse(fs.readFileSync(file,'utf8'))}catch{return emptyActive(agent,session)} -} -export function observeCheckpoint(root:string,agent:string,hook:string,input:any):void { - const session=typeof input.session_id==='string'?input.session_id:'';if(!session)return; - let kind='',text=''; - if(hook==='precompact')kind='compaction'; - if(hook==='session-end')kind='session-end'; - if(hook==='stop')kind='turn-end'; - if(hook==='user-prompt-submit'){kind='request';text=typeof input.prompt==='string'?input.prompt:''} - if(hook==='post-write'){kind='edit';const f=input.tool_input?.file_path??input.tool_input?.path??'';text=sensitivePath(f)?'[sensitive path omitted]':String(f)} - if(hook==='post-bash'){ - const command=input.tool_input?.command; - if(typeof command==='string'&&/\b(test|pytest|vitest|jest|cargo test|go test|build|check)\b/i.test(command)){kind='validation-observation';text=`Command: ${command.slice(0,600)}; reported exit: ${input.tool_response?.exit_code??input.tool_response?.exitCode??'unavailable'}. Repository-at-execution hash unavailable; not current validation.`} - } - if(kind)queueCheckpoint(root,agent,session,{id:input.tool_use_id?hook+':'+input.tool_use_id:crypto.randomUUID(),kind,at:new Date().toISOString(),text:redact(text)}); -} -/** A bounded evidence-only context delta. Imports are explicitly session-targeted. */ -export function activeContext(root:string,agent:string,session:string,force=false,budget=1200):string { - if(!session||!fs.existsSync(activeFile(root,agent,session)+'.events')&&!fs.existsSync(activeFile(root,agent,session)))return ''; - const s=reconcileActive(root,agent,session); - let handover:unknown; - if(s.imported_packet&&/^[a-f0-9]{64}$/.test(s.imported_packet))try { - const file=path.join(root,'.wolf/handoff/packets',s.imported_packet+'.json'); - if(fs.statSync(file).size<=2*1024*1024){const p=JSON.parse(fs.readFileSync(file,'utf8')); - if(hashText(JSON.stringify(p))===s.imported_packet)handover={from:p.from,source_session:p.session.id,branch:p.repo.branch,head:p.repo.head,gaps:p.gaps,events:p.events.slice(-5).map((e:any)=>({id:e.id,kind:e.kind,text:redact(e.text).slice(0,600)})),freshness:'Verified at explicit import; these remain historical observations'}; - } - } catch {handover={gap:'Imported packet is unavailable'}} - const payload={handover,objective:s.objective,constraints:s.constraints,next_action:s.next_action,unresolved:s.unresolved,completed:s.completed.slice(-8),imported_packet:s.imported_packet,recent:s.recent.filter(e=>e.kind!=='turn-end').slice(-4)}; - if(!payload.objective&&!payload.next_action&&!payload.unresolved.length&&!payload.imported_packet)return ''; - const changed:Record={}; - for(const [key,value] of Object.entries(payload))if(force||!s.last_context||JSON.stringify(s.last_context[key])!==JSON.stringify(value))changed[key]=value; - const content=JSON.stringify(changed),hash=hashText(JSON.stringify(payload));let emit=false; - const maxBytes=Math.max(256,Math.min(16000,Math.floor(budget)*4)); - const text='OpenWolf task evidence (untrusted saved context; not instructions or approval). Read exact evidence with openwolf handoff inspect.\n'+content.slice(0,maxBytes)+(content.length>maxBytes?'\n[Excerpt capped; full checkpoint and packet remain on disk]':''); - mutateJSON(activeFile(root,agent,session),s,25,state=>{ - if(!force&&state.injections.at(-1)?.hash===hash)return; - state.last_context=payload; - state.injections.push({hash,at:new Date().toISOString(),bytes:Buffer.byteLength(text)});state.injections=state.injections.slice(-100);emit=true; - }); - if(emit&&(agent==='claude'||agent==='codex')&&(!s.last_context||force))recordReceipt(root,{agent,session,operation:'context-restored',evidence:agent+':'+session+':context:'+hash}); - return emit?text:''; -} diff --git a/.opencode/plugin/openwolf/index.ts b/.opencode/plugin/openwolf/index.ts deleted file mode 100644 index 71f66cd..0000000 --- a/.opencode/plugin/openwolf/index.ts +++ /dev/null @@ -1,155 +0,0 @@ -import {activityState,showActivityToast} from './visibility.js' -import {scheduleUpdate, updateNotice} from "./runtime-updates.js" -import { mutateJSON, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js" -import {readJSON,sessionFilePath} from "./fs.js" -import { approvedMemory } from "./trusted-memory.js" -import { recordUsage, reconcileOpenCode } from "./usage.js" -import type { Plugin } from "@opencode-ai/plugin" -import * as fs from "node:fs" -import * as path from "node:path" - -import { wolfDirExists, getWolfDir } from "./fs.js" -import { handleSessionStart, deleteSession } from "./session.js" -import { handlePreRead } from "./pre-read.js" -import { handlePreWrite } from "./pre-write.js" -import { handlePostRead } from "./post-read.js" -import { handlePostWrite } from "./post-write.js" -import { handleStop } from "./stop.js" - -/** - * OpenCode event payloads have carried the session id in different places - * across versions: top-level `session_id`/`sessionID` in older builds, and - * nested under `properties` (`properties.info.id` for session.created, - * `properties.sessionID` elsewhere) in newer ones. Accept all shapes. - */ -function extractSessionId(source: unknown): string { - if (!source || typeof source !== "object") return "" - const obj = source as { - session_id?: unknown - sessionID?: unknown - properties?: { info?: { id?: unknown }; sessionID?: unknown } - } - const candidates = [ - obj.session_id, - obj.sessionID, - obj.properties?.info?.id, - obj.properties?.sessionID, - ] - for (const c of candidates) { - if (typeof c === "string" && c) return c - } - return "" -} - -export const OpenWolf: Plugin = async ({ directory, client }) => { - let reconciliation: Promise = Promise.resolve() - const refreshUsage = (id?: string) => { - reconciliation = reconciliation.then(() => reconcileOpenCode(directory,client,id)).catch(error => console.warn(`OpenWolf usage reconciliation: ${error}`)) - return reconciliation - } - if (wolfDirExists(directory)) void refreshUsage() - - return { - event: async ({ event }: { event: { type: string; [key: string]: unknown } }) => { - if (event.type === "session.created" && !wolfDirExists(directory)) return - - if (!wolfDirExists(directory)) return - if (event.type === "message.updated") { - try {recordUsage(directory, (event as any).properties?.info)} catch(error) {console.warn(String(error))} - } - const sessionId = extractSessionId(event) - if (!sessionId) return - - if (event.type === "session.created") { - handleSessionStart(directory, sessionId) - scheduleUpdate(directory) - const notice=updateNotice(directory,sessionId) - if(notice)showActivityToast(client,notice) - } - - if (event.type === "session.idle" || event.type === "session.error" || event.type === "session.deleted") { - scheduleUpdate(directory) - const update=updateNotice(directory,sessionId) - const activity=update??activityState(directory,{agent:"opencode",session:sessionId,turn:String(readJSON>(sessionFilePath(path.join(getWolfDir(directory),"hooks"),sessionId),{}).stop_count??0),surface:"opencode-toast"}).message - if(activity)showActivityToast(client,activity) - await refreshUsage(sessionId) - handleStop(directory, sessionId) - } - if (event.type === "session.deleted") { - const file=sessionFilePath(path.join(getWolfDir(directory),"hooks"),sessionId) - mutateJSON>(file,{},HOOK_LOCK_BUDGET_MS,state=>{state.ended=new Date().toISOString()}) - deleteSession(sessionId) - } - }, - - "tool.execute.before": async (input: { tool: string; sessionID: string }, output: { args: Record }) => { - if (!wolfDirExists(directory)) return - - const sessionId = extractSessionId(input) - if (!sessionId) return - - const args: Record = output.args || {} - const tool = input.tool.toLowerCase() - - if (tool === "read") { - const filePath = String(args.filePath || args.file_path || "") - const isRangedRead = args.offset !== undefined || args.limit !== undefined - if (filePath) handlePreRead(directory, sessionId, filePath, isRangedRead) - } - - if (tool === "write" || tool === "edit") { - const filePath = String(args.filePath || args.file_path || "") - const content = String(args.content || "") - const oldStr = String(args.old_string || args.oldString || "") - const newStr = String(args.new_string || args.newString || "") - if (filePath) handlePreWrite(directory, sessionId, filePath, content, oldStr, newStr) - } - }, - - "tool.execute.after": async (input: { tool: string; sessionID: string; args: Record; callID?: string }, output: Record) => { - if (!wolfDirExists(directory)) return - - const sessionId = extractSessionId(input) - if (!sessionId) return - - const tool = input.tool.toLowerCase() - const args = input.args || {} - - if (tool === "read") { - const filePath = String(args.filePath || args.file_path || "") - const content = String((output as any).output || "") - if (filePath) handlePostRead(directory, sessionId, filePath, content, args.offset !== undefined || args.limit !== undefined, input.callID) - } - - if (tool === "write" || tool === "edit") { - const filePath = String(args.filePath || args.file_path || "") - const content = String(args.content || "") - const oldStr = String(args.old_string || args.oldString || "") - const newStr = String(args.new_string || args.newString || "") - if (filePath) handlePostWrite(directory, sessionId, input.tool, filePath, content, oldStr, newStr) - } - }, - - stop: async (input: Record) => { - if (!wolfDirExists(directory)) return - - const sessionId = extractSessionId(input) - if (!sessionId) return - - handleStop(directory, sessionId) - }, - - "experimental.chat.system.transform": async (_input: Record, output: { system: string[] }) => { - if (!wolfDirExists(directory)) return - - const wolfDir = getWolfDir(directory) - const openwolfPath = path.join(wolfDir, "OPENWOLF.md") - if (fs.existsSync(openwolfPath)) { - try { - const openwolfContent = approvedMemory(wolfDir, "OPENWOLF.md") - output.system.push(`\n\n${openwolfContent}\n`) - } catch {} - } - }, - } -} \ No newline at end of file diff --git a/.opencode/plugin/openwolf/knowledge-root.ts b/.opencode/plugin/openwolf/knowledge-root.ts deleted file mode 100644 index 68e410e..0000000 --- a/.opencode/plugin/openwolf/knowledge-root.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import {execFileSync} from 'node:child_process'; -/** Only durable bug knowledge is shared. Anatomy, usage, sessions and STATUS - * always remain in the worktree's own .wolf directory. - */ -export function sharedWolfDir(wolfDir: string): string { - const root=path.dirname(wolfDir); - try { - if (!fs.statSync(path.join(root,'.git')).isFile()) return wolfDir; - const common=execFileSync('git',['rev-parse','--path-format=absolute','--git-common-dir'],{cwd:root,encoding:'utf8',stdio:['ignore','pipe','ignore'],timeout:1000}).trim(); - if (path.basename(common) !== '.git') return wolfDir; - const shared=path.join(path.dirname(common),'.wolf'); - return fs.statSync(shared).isDirectory() ? shared : wolfDir; - } catch {return wolfDir;} -} diff --git a/.opencode/plugin/openwolf/ledger-math.ts b/.opencode/plugin/openwolf/ledger-math.ts deleted file mode 100644 index cdfac39..0000000 --- a/.opencode/plugin/openwolf/ledger-math.ts +++ /dev/null @@ -1,369 +0,0 @@ -import type { RealUsage } from "./shared.js"; - -// ───────────────────────────────────────────────────────────────────────────── -// Pure token-ledger math: types, folds, lifetime derivation, migrations. -// Deliberately free of value imports so tests (and any consumer) can load it -// straight from source under Node's type stripping. IO and session assembly -// live in ledger.ts. -// ───────────────────────────────────────────────────────────────────────────── - -export const MAX_LEDGER_SESSIONS = 200; - -export interface SessionFileRead { - count: number; - tokens: number; - first_read: string; - read_mtime?: number; - anatomy_hit?: boolean; -} - -export interface SessionData { - agent?: string; - session_id: string; - started: string; - files_read: Record; - files_written: Array<{ file: string; action: string; tokens: number; at: string }>; - edit_counts: Record; - anatomy_hits: number; - anatomy_misses: number; - repeated_reads_warned: number; - reads_denied?: number; - denied_tokens_saved?: number; - stop_count: number; - reminders_sent: Record; - pending_reminders?: string[]; - injected_tokens_estimated?: number; - injected_by_source?: Record; - bash_governed?: Array<{ family: string; action: string; original_tokens: number; entered_tokens: number; at: string }>; - [key: string]: unknown; -} - -export interface FamilyTotals { - calls: number; - original_tokens: number; - entered_tokens: number; -} - -/** Sum of two per-key maps, used for both family and per-model rollups. */ -export function foldMap( - target: Record, - source: Record | undefined -): void { - for (const [key, value] of Object.entries(source ?? {})) { - if (!value || typeof value !== "object") continue; - const bucket = (target[key] ??= {} as T); - for (const [field, n] of Object.entries(value)) { - if (typeof n !== "number" || !isFinite(n)) continue; - (bucket as Record)[field] = ((bucket as Record)[field] ?? 0) + n; - } - } -} - -export interface SessionEntry { - id: string; - agent: string; - started: string; - ended: string; - reads: Array<{ - file: string; - tokens_estimated: number; - was_repeated: boolean; - anatomy_had_description: boolean; - }>; - writes: Array<{ file: string; tokens_estimated: number; action: string }>; - totals: { - input_tokens_estimated: number; - output_tokens_estimated: number; - reads_count: number; - writes_count: number; - repeated_reads_blocked: number; - repeated_reads_warned?: number; - anatomy_lookups: number; - anatomy_misses?: number; - savings_estimated?: number; - /** Tokens OpenWolf itself injected into context (digests, hints, warnings). */ - injection_tokens_estimated?: number; - /** Bash governor (2.3): original size of governed outputs vs what entered. */ - bash_governed_original_tokens?: number; - bash_governed_entered_tokens?: number; - bash_governed_calls?: number; - /** Per-command-family governor breakdown (2.5). The scalars above cannot - * answer "which families are actually paying off" — the classifier - * already knows the family at the rewrite point, so keep it. */ - bash_governed_by_family?: Record; - /** Tokens the agent spent reading .wolf/ state itself (2.4). */ - wolf_internal_tokens?: number; - }; - injected_by_source?: Record; - real_usage?: RealUsage; - /** Transcript-verified hook activity (2.2). Absent = verification - * unavailable (old transcript, format drift, or non-Claude agent); the - * self-reported totals are then estimates, not facts. */ - verified?: { - hooks_fired: number; - hooks_failed: number; - injections_delivered: number; - injection_tokens_delivered: number; - per_hook: Record; - last_failure?: { hook: string; stderr_head: string }; - }; -} - -export interface LifetimeTotals { - total_tokens_estimated: number; - total_reads: number; - total_writes: number; - total_sessions: number; - anatomy_hits: number; - anatomy_misses: number; - repeated_reads_blocked: number; - repeated_reads_warned: number; - estimated_savings_vs_bare_cli: number; - injection_tokens_estimated: number; - [key: string]: number; -} - -export interface LifetimeMaps { - /** Governor deltas per command family, lifetime. */ - bash_governed_by_family: Record; - /** Measured usage per model id, lifetime. Drives the cost figure. */ - real_by_model: Record; -} - -export interface ModelUsage { - input_tokens: number; - output_tokens: number; - cache_read_input_tokens: number; - cache_creation_input_tokens: number; - api_calls: number; -} - -export interface LedgerData { - version: number; - created_at: string; - lifetime: LifetimeTotals; - /** Totals folded out of sessions that were rolled off the retained window. */ - lifetime_baseline?: Partial; - /** Lifetime per-key rollups. Kept out of LifetimeTotals, whose index - * signature is number, and out of lifetime_baseline, which numericFields() - * strips down to scalars on every trim. */ - lifetime_maps?: LifetimeMaps; - lifetime_baseline_maps?: LifetimeMaps; - sessions: SessionEntry[]; - [key: string]: unknown; -} - -export function emptyLedger(): LedgerData { - return { - 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, - repeated_reads_warned: 0, - estimated_savings_vs_bare_cli: 0, - injection_tokens_estimated: 0, - }, - sessions: [], - daemon_usage: [], - waste_flags: [], - optimization_report: { last_generated: null, patterns: [] }, - }; -} - -/** The totals block of a session entry, derived from live session state. */ -export function buildSessionTotals( - session: SessionData, - reads: SessionEntry["reads"], - writes: SessionEntry["writes"] -): SessionEntry["totals"] { - return { - input_tokens_estimated: reads.reduce((sum, r) => sum + r.tokens_estimated, 0), - output_tokens_estimated: writes.reduce((sum, w) => sum + w.tokens_estimated, 0), - reads_count: reads.length, - writes_count: writes.length, - // Honest accounting: only reads the hook actually denied count as - // blocked (warnings do not prevent the read from happening). - repeated_reads_blocked: session.reads_denied ?? 0, - repeated_reads_warned: session.repeated_reads_warned ?? 0, - anatomy_lookups: session.anatomy_hits, - anatomy_misses: session.anatomy_misses, - // Honest savings: tokens of reads that were denied, nothing else. - savings_estimated: session.denied_tokens_saved ?? 0, - // The other side of the scale: what OpenWolf's own context injection cost. - injection_tokens_estimated: session.injected_tokens_estimated ?? 0, - // Bash governor deltas: original-vs-entered is measured at the rewrite - // point, which nothing else in the ecosystem can observe. - bash_governed_calls: (session.bash_governed ?? []).length || undefined, - bash_governed_original_tokens: sumBy(session.bash_governed, (g) => g.original_tokens), - bash_governed_entered_tokens: sumBy(session.bash_governed, (g) => g.entered_tokens), - bash_governed_by_family: byFamily(session.bash_governed), - wolf_internal_tokens: typeof session.wolf_internal_tokens === "number" ? session.wolf_internal_tokens : undefined, - }; -} - -/** - * Group the session's governed calls by command family. The session file keeps - * only the last 200 records, so this is computed at flush time while they are - * still there; the ledger then carries the rollup forever. - */ -function byFamily( - governed: SessionData["bash_governed"] -): Record | undefined { - if (!governed || governed.length === 0) return undefined; - const out: Record = {}; - for (const g of governed) { - const key = typeof g.family === "string" && g.family ? g.family : "other"; - const bucket = (out[key] ??= { calls: 0, original_tokens: 0, entered_tokens: 0 }); - bucket.calls++; - if (isFinite(g.original_tokens)) bucket.original_tokens += g.original_tokens; - if (isFinite(g.entered_tokens)) bucket.entered_tokens += g.entered_tokens; - } - return out; -} - -function sumBy(arr: T[] | undefined, fn: (item: T) => number): number | undefined { - if (!arr || arr.length === 0) return undefined; - return arr.reduce((sum, item) => sum + fn(item), 0); -} - -/** - * Position-weighted context cost (2.2). Fresh input is ~0.004% of a session's - * input-side tokens; the real cost of a byte is that it sits in the cached - * prefix and is re-read at the cache-read rate on EVERY subsequent API call: - * cost = tokens x (total_calls - call_index) x rate_per_token - * A 10k-token read at call 500 of 1,458 costs ~$2.87 (Sonnet-class rates); - * the same read at call 1,450 costs ~$0.02. Waste rankings must use this, - * not raw token counts. - */ -export function positionWeightedCostUsd( - tokens: number, - callIndex: number, - totalCalls: number, - cacheReadUsdPerMTok: number -): number { - const remaining = Math.max(0, totalCalls - callIndex); - return (tokens * remaining * cacheReadUsdPerMTok) / 1_000_000; -} - -export function addInto(target: Record, key: string, value: number | undefined): void { - if (typeof value !== "number" || !isFinite(value)) return; - target[key] = (target[key] ?? 0) + value; -} - -/** Copy only real numeric fields out of a possibly-partial totals object. */ -export function numericFields(source: Record | undefined): Record { - const out: Record = {}; - for (const [k, v] of Object.entries(source ?? {})) { - if (typeof v === "number" && isFinite(v)) out[k] = v; - } - return out; -} - -/** - * Fold one session's per-key rollups into a LifetimeMaps accumulator. Kept - * separate from foldEntry because those maps cannot live in the scalar - * accumulator that numericFields() produces. - */ -export function emptyMaps(): LifetimeMaps { - return { bash_governed_by_family: {}, real_by_model: {} }; -} - -export function foldEntryMaps(acc: LifetimeMaps, e: SessionEntry): void { - foldMap(acc.bash_governed_by_family, e.totals?.bash_governed_by_family); - foldMap(acc.real_by_model, e.real_usage?.per_model as Record | undefined); -} - -export function foldEntry(acc: Record, e: SessionEntry): void { - addInto(acc, "total_tokens_estimated", e.totals.input_tokens_estimated + e.totals.output_tokens_estimated); - addInto(acc, "total_reads", e.totals.reads_count); - addInto(acc, "total_writes", e.totals.writes_count); - addInto(acc, "anatomy_hits", e.totals.anatomy_lookups); - addInto(acc, "anatomy_misses", e.totals.anatomy_misses); - addInto(acc, "repeated_reads_blocked", e.totals.repeated_reads_blocked); - addInto(acc, "repeated_reads_warned", e.totals.repeated_reads_warned); - addInto(acc, "estimated_savings_vs_bare_cli", e.totals.savings_estimated); - addInto(acc, "injection_tokens_estimated", e.totals.injection_tokens_estimated); - addInto(acc, "bash_governed_calls", e.totals.bash_governed_calls); - addInto(acc, "bash_governed_original_tokens", e.totals.bash_governed_original_tokens); - addInto(acc, "bash_governed_entered_tokens", e.totals.bash_governed_entered_tokens); - addInto(acc, "wolf_internal_tokens", e.totals.wolf_internal_tokens); - if (e.real_usage) { - addInto(acc, "real_input_tokens", e.real_usage.input_tokens); - addInto(acc, "real_output_tokens", e.real_usage.output_tokens); - addInto(acc, "real_cache_read_tokens", e.real_usage.cache_read_input_tokens); - addInto(acc, "real_cache_creation_tokens", e.real_usage.cache_creation_input_tokens); - addInto(acc, "real_api_calls", e.real_usage.api_calls); - } -} - -/** - * Derive lifetime = baseline + fold(sessions). total_sessions is intentionally - * NOT derived here — session-start counts it once per new session. - */ -export function recomputeLifetime(ledger: LedgerData): void { - const acc = numericFields(ledger.lifetime_baseline); - delete acc.total_sessions; - const maps = emptyMaps(); - foldMap(maps.bash_governed_by_family, ledger.lifetime_baseline_maps?.bash_governed_by_family); - foldMap(maps.real_by_model, ledger.lifetime_baseline_maps?.real_by_model); - for (const e of ledger.sessions) { - foldEntry(acc, e); - foldEntryMaps(maps, e); - } - ledger.lifetime_maps = maps; - const totalSessions = ledger.lifetime?.total_sessions ?? 0; - ledger.lifetime = { - total_tokens_estimated: 0, - total_reads: 0, - total_writes: 0, - anatomy_hits: 0, - anatomy_misses: 0, - repeated_reads_blocked: 0, - repeated_reads_warned: 0, - estimated_savings_vs_bare_cli: 0, - injection_tokens_estimated: 0, - ...acc, - total_sessions: totalSessions, - } as LifetimeTotals; -} - -/** - * One-time legacy migration: sessions written before 2.0.5 stored the number - * of duplicate-read WARNINGS in totals.repeated_reads_blocked (the field - * predates deny mode). Under the current semantics a blocked read always - * credits savings_estimated > 0 (a denial saves the previous read's tokens, - * and denyEligible requires tokens > 0), so blocked > 0 with zero savings can - * only be legacy data. Move those counts to repeated_reads_warned and zero - * the blocked field. Also migrates the lifetime_baseline, which may hold - * folded-off legacy sessions. Returns the number of records rewritten. - * Caller is responsible for recomputeLifetime() and persisting. - */ -export function migrateLegacyBlockedCounts(ledger: LedgerData): number { - let migrated = 0; - for (const s of ledger.sessions ?? []) { - const t = s?.totals; - if (!t) continue; - if ((t.repeated_reads_blocked ?? 0) > 0 && !((t.savings_estimated ?? 0) > 0)) { - t.repeated_reads_warned = (t.repeated_reads_warned ?? 0) + t.repeated_reads_blocked; - t.repeated_reads_blocked = 0; - migrated++; - } - } - const base = ledger.lifetime_baseline; - if ( - base && - (base.repeated_reads_blocked ?? 0) > 0 && - !((base.estimated_savings_vs_bare_cli ?? 0) > 0) - ) { - base.repeated_reads_warned = (base.repeated_reads_warned ?? 0) + (base.repeated_reads_blocked ?? 0); - base.repeated_reads_blocked = 0; - migrated++; - } - return migrated; -} diff --git a/.opencode/plugin/openwolf/ledger.ts b/.opencode/plugin/openwolf/ledger.ts deleted file mode 100644 index 2e7583f..0000000 --- a/.opencode/plugin/openwolf/ledger.ts +++ /dev/null @@ -1,133 +0,0 @@ -import * as fs from "node:fs"; -import * as crypto from "node:crypto"; -import * as path from "node:path"; -import { readJSON, writeJSON, readTranscriptUsage, detectAgent } from "./shared.js"; -import { withFileLock, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js"; -import { - MAX_LEDGER_SESSIONS, - emptyLedger, - buildSessionTotals, - numericFields, - foldEntry, - foldEntryMaps, - emptyMaps, - recomputeLifetime, - type SessionData, - type SessionEntry, - type LedgerData, -} from "./ledger-math.js"; - -// ───────────────────────────────────────────────────────────────────────────── -// Token-ledger writer shared by the Stop and SessionEnd hooks. -// -// Stop fires at the end of EVERY turn, so the ledger write must be idempotent: -// the session entry is UPSERTED by session id (replaced, never appended), and -// lifetime totals are derived — archived baseline + fold over the retained -// sessions — rather than incremented. The old increment-on-every-Stop scheme -// re-added turns 1..N on turn N, quadratically inflating every lifetime metric -// and duplicating session entries. -// -// The pure math (folds, lifetime derivation, migrations) lives in -// ledger-math.ts; this module owns IO and session-entry assembly. -// ───────────────────────────────────────────────────────────────────────────── - -export * from "./ledger-math.js"; - -/** Build the ledger entry for the current session state (idempotent). */ -export function buildSessionEntry(session: SessionData, transcriptPath?: string): SessionEntry { - const reads = Object.entries(session.files_read).map(([file, data]) => ({ - file, - tokens_estimated: data.tokens, - was_repeated: data.count > 1, - anatomy_had_description: data.anatomy_hit === true, - })); - - const writes = session.files_written.map((w) => ({ - file: w.file, - tokens_estimated: w.tokens, - action: w.action, - })); - - const entry: SessionEntry = { - id: session.session_id, - agent: typeof session.agent === "string" ? session.agent : detectAgent(), - started: session.started, - ended: new Date().toISOString(), - reads, - writes, - totals: buildSessionTotals(session, reads, writes), - }; - - if (session.injected_by_source && Object.keys(session.injected_by_source).length > 0) { - entry.injected_by_source = { ...session.injected_by_source }; - } - - if (transcriptPath) { - // readTranscriptUsage dedupes by message id, so this is the cumulative - // total for the whole session — correct to REPLACE, never to add. - const real = readTranscriptUsage(transcriptPath); - if (real) entry.real_usage = real; - } - - return entry; -} - -/** Upsert the entry, roll old sessions into the baseline, derive lifetime. */ -export function flushSessionToLedger(wolfDir: string, entry: SessionEntry): void { - if (!entry.id) return; - const pending = path.join(wolfDir,"ledger-pending"); - fs.mkdirSync(pending,{recursive:true}); - const file = path.join(pending,crypto.randomUUID()+".json"); - const fd = fs.openSync(file,"wx",0o600); - try { fs.writeFileSync(fd,JSON.stringify(entry)); fs.fsyncSync(fd); } finally { fs.closeSync(fd); } - reconcileLedger(wolfDir); -} - -/** All per-session snapshots survive the 200-row UI window. A late resumed - * session replaces its old snapshot instead of being added to baseline twice. - */ -export function reconcileLedger(wolfDir: string): boolean { - const ledgerPath = path.join(wolfDir,"token-ledger.json"); - const pending = path.join(wolfDir,"ledger-pending"); - const snapshots = path.join(wolfDir,"ledger-sessions"); - fs.mkdirSync(snapshots,{recursive:true}); - const result = withFileLock(ledgerPath+".lock",HOOK_LOCK_BUDGET_MS,()=>{ - const read = (file: string, fallback: T): T => { - try {return JSON.parse(fs.readFileSync(file,"utf8"));} catch(e) {if ((e as NodeJS.ErrnoException).code === "ENOENT") return fallback; throw e;} - }; - const ledger = read(ledgerPath,emptyLedger()); - const baselineFile = path.join(wolfDir,"_legacy-ledger-baseline.json"); - const legacy = read(baselineFile,{totals:ledger.lifetime_baseline ?? {},maps:ledger.lifetime_baseline_maps ?? emptyMaps()}); - if (!fs.existsSync(baselineFile)) writeJSON(baselineFile,legacy); - const key = (e: SessionEntry) => crypto.createHash("sha256").update(`${e.agent ?? 'claude'}:${e.id}`).digest("hex")+".json"; - for (const e of ledger.sessions ?? []) { - if (!e?.id) continue; - const dest = path.join(snapshots,key(e)); - if (!fs.existsSync(dest)) writeJSON(dest,e); - } - let names: string[] = []; - try {names=fs.readdirSync(pending).filter(n=>n.endsWith(".json"));} catch {} - for (const name of names) { - const e = read(path.join(pending,name),null); - if (!e?.id || !e.totals) throw new Error("Invalid pending ledger entry retained for review"); - const dest = path.join(snapshots,key(e)); - const previous = read(dest,null); - if (!previous || e.ended >= previous.ended) writeJSON(dest,e); - } - const all = fs.readdirSync(snapshots).filter(n=>n.endsWith(".json")).map(n=>read(path.join(snapshots,n),null as any)) - .sort((a,b)=>a.started.localeCompare(b.started) || a.id.localeCompare(b.id)); - ledger.sessions = all.slice(-MAX_LEDGER_SESSIONS); - const baseline = numericFields(legacy.totals); - ledger.lifetime_baseline = baseline; - ledger.lifetime_baseline_maps = structuredClone(legacy.maps); - for (const old of all.slice(0,Math.max(0,all.length-MAX_LEDGER_SESSIONS))) { - foldEntry(baseline,old); - foldEntryMaps(ledger.lifetime_baseline_maps,old); - } - recomputeLifetime(ledger); - writeJSON(ledgerPath,ledger); - for (const name of names) {try {fs.unlinkSync(path.join(pending,name));} catch {}} - return true; - }); - return result === true; -} diff --git a/.opencode/plugin/openwolf/post-read.ts b/.opencode/plugin/openwolf/post-read.ts deleted file mode 100644 index e772dc7..0000000 --- a/.opencode/plugin/openwolf/post-read.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { persistRead, reconcileReads } from "./event-journal.js" -import * as crypto from "node:crypto" -import * as fs from "node:fs" -import * as path from "node:path" -import { getWolfDir, writeJSON, readJSON, normalizePath, estimateTokens, sessionFilePath } from "./fs.js" -import { lookupEntry } from "./anatomy.js" -import type { PartialSessionState } from "./types.js" - -export function handlePostRead(directory: string, sessionId: string, filePath: string, content: string, ranged = false, observationId?: string): void { - if (ranged) return - filePath=path.resolve(directory,filePath) - const wolfDir = getWolfDir(directory) - if (!fs.existsSync(wolfDir)) return - - const hooksDir = path.join(wolfDir, "hooks") - const sessionFile = sessionFilePath(hooksDir, sessionId) - const normalizedFile = normalizePath(filePath) - - const projectDir = normalizePath(directory) - const rel=path.relative(directory,filePath) - if (!rel || rel==='..' || rel.startsWith('..'+path.sep) || path.isAbsolute(rel) || rel.split(path.sep)[0]==='.wolf') return - - const ext = path.extname(filePath).toLowerCase() - const codeExts = new Set([".ts", ".js", ".tsx", ".jsx", ".py", ".rs", ".go", ".java", ".c", ".cpp", ".css", ".json", ".yaml", ".yml"]) - const proseExts = new Set([".md", ".txt", ".rst"]) - const type = codeExts.has(ext) ? "code" : proseExts.has(ext) ? "prose" : "mixed" - - let tokens = content ? estimateTokens(content, type as "code" | "prose" | "mixed") : 0 - - // Fallback: if the tool output had no content, use the anatomy token estimate - if (tokens === 0) { - const entry = lookupEntry(wolfDir, projectDir, normalizedFile) - if (entry) tokens = entry.tokens - } - - let fingerprint: string | undefined - let mtime=0 - try {const current=fs.readFileSync(filePath,"utf8");if(current===content){fingerprint=crypto.createHash("sha256").update(current).digest("hex");mtime=fs.statSync(filePath).mtimeMs}}catch{} - persistRead(sessionFile,{id:observationId ?? crypto.randomUUID(),file:normalizedFile,at:new Date().toISOString(),tokens,fingerprint,mtime}) - reconcileReads(sessionFile) -} diff --git a/.opencode/plugin/openwolf/post-write.ts b/.opencode/plugin/openwolf/post-write.ts deleted file mode 100644 index b5a1215..0000000 --- a/.opencode/plugin/openwolf/post-write.ts +++ /dev/null @@ -1,261 +0,0 @@ -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("; ") -} \ No newline at end of file diff --git a/.opencode/plugin/openwolf/pre-read.ts b/.opencode/plugin/openwolf/pre-read.ts deleted file mode 100644 index 72b44b5..0000000 --- a/.opencode/plugin/openwolf/pre-read.ts +++ /dev/null @@ -1,100 +0,0 @@ -import * as fs from "node:fs" -import * as path from "node:path" -import { getWolfDir, writeJSON, readJSON, normalizePath, sessionFilePath } from "./fs.js" -import { lookupEntry } from "./anatomy.js" -import type { PartialSessionState } from "./types.js" - -export function handlePreRead(directory: string, sessionId: string, filePath: string, isRangedRead = false): void { - filePath=path.resolve(directory,filePath) - const relative=path.relative(directory,filePath) - if (!relative || relative === ".." || relative.startsWith(".."+path.sep) || path.isAbsolute(relative)) return - const wolfDir = getWolfDir(directory) - if (!fs.existsSync(wolfDir)) return - - const hooksDir = path.join(wolfDir, "hooks") - const sessionFile = sessionFilePath(hooksDir, sessionId) - const normalizedFile = normalizePath(filePath) - - const projectDir = normalizePath(directory) - const relToProject = normalizedFile.startsWith(projectDir) - ? normalizedFile.slice(projectDir.length).replace(/^\//, "") - : "" - if (relToProject.startsWith(".wolf/") || relToProject.startsWith(".wolf\\")) return - - // Ranged reads (offset/limit) are exactly what the symbol hints steer the - // model toward — never warn about them or record them as full reads (a - // ranged first contact must not make a later legitimate full read look like - // a duplicate). - if (isRangedRead) return - - const session = readJSON(sessionFile, { - session_id: "", files_read: {}, anatomy_hits: 0, anatomy_misses: 0, - repeated_reads_warned: 0, - }) - - if (!session.files_read) session.files_read = {} - - // Repeat detection is mtime-gated (issue #41): only warn while the file is - // unchanged since the recorded read. A modified file is a legitimate - // re-read — drop the stale record and track it fresh below. - if (session.files_read[normalizedFile]) { - const prev = session.files_read[normalizedFile] - let modifiedSinceRead = true - try { - const mtime = fs.statSync(filePath).mtimeMs - modifiedSinceRead = prev.read_mtime === undefined || mtime !== prev.read_mtime - } catch {} - if (!modifiedSinceRead) { - prev.count++ - session.repeated_reads_warned = (session.repeated_reads_warned || 0) + 1 - console.warn(`OpenWolf: ${path.basename(normalizedFile)} was already read this session (~${prev.tokens} tok), unchanged since. If you only need the gist, your earlier read may suffice; for exact text (edit anchors, line numbers), the re-read is fine.`) - writeJSON(sessionFile, session) - return - } - delete session.files_read[normalizedFile] - } - - // Anatomy lookup: O(1) against the durable store, legacy md scan fallback. - const entry = lookupEntry(wolfDir, projectDir, normalizedFile) - const found = entry !== null - if (entry) { - if (entry.description) { - console.warn(`OpenWolf anatomy: ${entry.file}: ${entry.description} (~${entry.tokens} tok)`) - } - - // Symbol hint: point at slices of big files. Suppressed if the on-disk - // file no longer matches what was indexed — a stale line range that - // misdirects an offset read is worse than no hint at all. - if (entry.symbols && entry.symbols.length > 0) { - let fresh = false - try { - const st = fs.statSync(filePath) - fresh = (entry.size === undefined || st.size === entry.size) && - (entry.mtimeMs === undefined || Math.abs(st.mtimeMs - entry.mtimeMs) < 1) - } catch {} - if (fresh) { - const top = [...entry.symbols].sort((a, b) => b.tokens - a.tokens).slice(0, 5) - const list = top.map((s) => `${s.kind} ${s.name} L${s.startLine}-${s.endLine} ~${s.tokens} tok`).join("; ") - console.warn(`Largest sections: ${list}. Read with offset/limit to fetch just the part you need.`) - } - } - } - - session.anatomy_hits = (session.anatomy_hits || 0) + (found ? 1 : 0) - session.anatomy_misses = (session.anatomy_misses || 0) + (found ? 0 : 1) - - // Record initial read entry (tokens will be updated in post-read) - let readMtime: number | undefined - try { - readMtime = fs.statSync(filePath).mtimeMs - } catch {} - session.files_read[normalizedFile] = { - count: 1, - tokens: 0, - first_read: new Date().toISOString(), - read_mtime: readMtime, - anatomy_hit: found, - } - - writeJSON(sessionFile, session) -} diff --git a/.opencode/plugin/openwolf/pre-write.ts b/.opencode/plugin/openwolf/pre-write.ts deleted file mode 100644 index d05f7d8..0000000 --- a/.opencode/plugin/openwolf/pre-write.ts +++ /dev/null @@ -1,110 +0,0 @@ -import {recordReceipt} from './visibility.js'; -import { sharedWolfDir } from "./knowledge-root.js"; -import { approvedMemory } from "./trusted-memory.js" -import * as fs from "node:fs" -import * as path from "node:path" -import { getWolfDir, readMarkdown, normalizePath, readJSON } from "./fs.js" - -const STOP_WORDS = new Set([ - "error", "function", "return", "const", "this", "that", "with", "from", - "import", "export", "class", "interface", "type", "undefined", "null", - "true", "false", "string", "number", "object", "array", "value", - "file", "path", "name", "data", "response", "request", "result", - "should", "must", "does", "have", "been", "will", "would", "could", - "when", "then", "else", "each", "some", "every", "only", -]) - -function tokenize(text: string): Set { - return new Set( - text.replace(/[^\w\s]/g, " ").split(/\s+/) - .filter(w => w.length > 3 && !STOP_WORDS.has(w.toLowerCase())) - .map(w => w.toLowerCase()) - ) -} - -export function handlePreWrite(directory: string, sessionId: string, filePath: string, content: string, oldStr: string, newStr: string): void { - const wolfDir = getWolfDir(directory) - if (!fs.existsSync(wolfDir)) return - - const allContent = [content, oldStr, newStr].join("\n") - if (!allContent.trim()) return - - checkCerebrum(wolfDir, allContent) - - if (filePath && (oldStr || content)) { - checkBugLog(wolfDir, filePath, oldStr, newStr, content, sessionId) - } -} - -function checkCerebrum(wolfDir: string, content: string): void { - const cerebrumContent = approvedMemory(wolfDir, "cerebrum.md") - const doNotRepeatSection = cerebrumContent.split("## Do-Not-Repeat")[1] - if (!doNotRepeatSection) return - - const entries = doNotRepeatSection.split("## ")[0] - const lines = entries.split("\n").filter((l) => l.trim().startsWith("[") || l.trim().startsWith("-")) - for (const line of lines) { - const trimmed = line.trim().replace(/^[-*]\s*/, "").replace(/^\[[\d-]+\]\s*/, "") - if (!trimmed) continue - const patterns: string[] = [] - const quotedMatches = trimmed.match(/"([^"]+)"/g) || trimmed.match(/'([^']+)'/g) || trimmed.match(/`([^`]+)`/g) - if (quotedMatches) { - for (const qm of quotedMatches) { - patterns.push(qm.replace(/["'`]/g, "")) - } - } - const neverMatch = trimmed.match(/(?:never use|avoid|don't use|do not use)\s+(\w+)/i) - if (neverMatch) patterns.push(neverMatch[1]) - for (const pattern of patterns) { - try { - const regex = new RegExp(`\\b${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i") - if (regex.test(content)) { - console.warn(`⚠️ OpenWolf cerebrum warning: "${trimmed}" — check your code before proceeding.`) - } - } catch {} - } - } -} - -interface BugEntry { - id: string - error_message: string - root_cause: string - fix: string - file: string - tags: string[] -} - -function checkBugLog(wolfDir: string, filePath: string, oldStr: string, newStr: string, content: string, sessionId: string): void { - const bugLogPath = path.join(sharedWolfDir(wolfDir), "buglog.json") - if (!fs.existsSync(bugLogPath)) return - - const bugLog = readJSON<{ version: number; bugs: BugEntry[] }>(bugLogPath, { version: 1, bugs: [] }) - if (bugLog.bugs.length === 0) return - - const basename = path.basename(filePath) - const fileMatches = bugLog.bugs.filter((b: BugEntry) => path.basename(b.file) === basename) - if (fileMatches.length === 0) return - - const editText = (oldStr + " " + newStr + " " + content).toLowerCase() - const editTokens = tokenize(editText) - - const relevant = fileMatches.filter((bug: BugEntry) => { - const tagHit = bug.tags.some((t: string) => editText.includes(t.toLowerCase())) - if (tagHit) return true - const bugTokens = tokenize(bug.error_message + " " + bug.root_cause) - const overlap = [...editTokens].filter(t => bugTokens.has(t)) - return overlap.length >= 3 - }) - - if (relevant.length === 0) return - const fixes=relevant.slice(0,2).filter(b=>typeof b.fix==="string"&&b.fix.trim()) - if(fixes.length)recordReceipt(path.dirname(wolfDir),{agent:"opencode",session:sessionId,operation:"fix-retrieved",evidence:JSON.stringify(fixes.map(b=>[b.id,b.fix])),count:fixes.length}) - - console.warn(`📋 OpenWolf buglog: ${relevant.length} past bug(s) found for ${basename} — review for context, do NOT apply blindly:`) - for (const bug of relevant.slice(0, 2)) { - console.warn(` [${bug.id}] "${bug.error_message.slice(0, 70)}"`) - console.warn(` Cause: ${bug.root_cause.slice(0, 80)}`) - console.warn(` Fix: ${bug.fix.slice(0, 80)}`) - } -} \ No newline at end of file diff --git a/.opencode/plugin/openwolf/runtime-updates.ts b/.opencode/plugin/openwolf/runtime-updates.ts deleted file mode 100644 index 288dc44..0000000 --- a/.opencode/plugin/openwolf/runtime-updates.ts +++ /dev/null @@ -1,119 +0,0 @@ -import {visibilityMode} from './visibility.js'; -/** Session-pinned npm runtimes. This module performs local I/O only. */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as crypto from 'node:crypto'; -import {spawn} from 'node:child_process'; -import {pathToFileURL, fileURLToPath} from 'node:url'; - -export const RUNTIME_PROTOCOL = 1; -export const CHECK_INTERVAL = 24 * 3600_000; -export interface UpdateState { - checkedAt?: number; latest?: string; selected?: string; previous?: string; - status?: 'current'|'ready'|'available'|'error'|'unsupported'; detail?: string; -} -export const stableVersion = (v: unknown): v is string => typeof v === 'string' && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(v) && v.split('.').every(n=>Number.isSafeInteger(Number(n))); -export function newer(a: string,b: string): boolean { - if (!stableVersion(a)||!stableVersion(b)) return false; - const x=a.split('.').map(Number),y=b.split('.').map(Number); - for(let i=0;i<3;i++)if(x[i]!==y[i])return x[i]>y[i]; - return false; -} -export function compatible(a:string,b:string):boolean { - if(!stableVersion(a)||!stableVersion(b))return false; - const x=a.split('.'),y=b.split('.'); - return x[0]===y[0] && (x[0]!=='0'||x[1]===y[1]); -} -export function readObject(file:string):any {try{const v=JSON.parse(fs.readFileSync(file,'utf8'));return v&&typeof v==='object'&&!Array.isArray(v)?v:{}}catch{return {}}} -export function updatesDir(root:string):string{return path.join(root,'.wolf','updates')} -export function updateState(root:string):UpdateState {return readObject(path.join(updatesDir(root),'state.json'))} -export function atomicObject(file:string,value:unknown):void { - fs.mkdirSync(path.dirname(file),{recursive:true});const temp=file+'.'+crypto.randomUUID()+'.tmp'; - try{const fd=fs.openSync(temp,'wx',0o600);try{fs.writeFileSync(fd,JSON.stringify(value));fs.fsyncSync(fd)}finally{fs.closeSync(fd)}fs.renameSync(temp,file)}finally{try{fs.unlinkSync(temp)}catch{}} -} -export function updatePolicy(root:string):'compatible'|'all'|'notify'|'off' { - const file=path.join(root,'.wolf','config.json'); - let cfg:any; - try { - const parsed=JSON.parse(fs.readFileSync(file,'utf8')); - if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))return 'off'; - cfg=parsed.openwolf; - } catch(error) { if((error as NodeJS.ErrnoException).code!=='ENOENT')return 'off'; } - if(cfg?.enabled===false || process.env.OPENWOLF_NO_UPDATE==='1')return 'off'; - const mode=cfg?.updates?.mode ?? 'compatible'; - return ['compatible','all','notify','off'].includes(mode)?mode:'off'; -} -export function installedVersion(root:string):string { - const v=readObject(path.join(root,'.wolf','hooks','runtime.json')).version; - return stableVersion(v)?v:'unknown'; -} -export function releasePackage(root:string,version:string):string { - if(!stableVersion(version))throw new Error('Invalid release version'); - return path.join(updatesDir(root),'releases',version,'node_modules','openwolf'); -} -export function readyRelease(root:string,version:unknown):string|undefined { - if(!stableVersion(version))return; - const pkg=releasePackage(root,version),meta=readObject(path.join(pkg,'package.json')); - const ready=readObject(path.join(updatesDir(root),'releases',version,'ready.json')); - if(meta.name==='openwolf'&&meta.version===version&&meta.openwolfRuntime?.protocol===RUNTIME_PROTOCOL&&ready.version===version)return pkg; -} -// Windows reports uid=0 for ordinary files; it is not POSIX root ownership. -// Protected authority on Windows is unsupported and remains disabled by its verifier. -const protectedRuntime=()=>process.platform!=='win32'&&fs.statSync(fileURLToPath(import.meta.url)).uid===0; -/** Never select a new version for an existing session (including resume). */ -export function pinRuntime(root:string,session:string):string|undefined { - if(!session || !stableVersion(installedVersion(root)) || protectedRuntime())return; // protected runtime or no identity => installed - const dir=path.join(updatesDir(root),'pins'); - const file=path.join(dir,crypto.createHash('sha256').update(session).digest('hex')+'.json'); - let pin=readObject(file); - if(!fs.existsSync(file)) { - const candidate=updateState(root).selected; - const version=updatePolicy(root)!=='off'&&stableVersion(candidate)&&newer(candidate,installedVersion(root))&&readyRelease(root,candidate)?candidate:null; - fs.mkdirSync(dir,{recursive:true}); - const tmp=file+'.'+crypto.randomUUID()+'.tmp'; - try{fs.writeFileSync(tmp,JSON.stringify({version}),{flag:'wx',mode:0o600});try{fs.linkSync(tmp,file)}catch(e){if((e as NodeJS.ErrnoException).code!=='EEXIST')throw e}}finally{try{fs.unlinkSync(tmp)}catch{}} - pin=readObject(file); - } - return readyRelease(root,pin.version); -} -/** At most one detached, bounded worker per project/check interval. */ -export function scheduleUpdate(root:string):void { - try { - if(updatePolicy(root)==='off'||protectedRuntime())return; - const state=updateState(root); - if(typeof state.checkedAt==='number'&&Date.now()-state.checkedAt{try{fs.unlinkSync(request)}catch{}});child.unref(); - } catch {} // unavailable npm/network must never interrupt a coding session -} -const bridgeKey=Symbol.for('openwolf.hook.runtime'); -export function bridgedInput():string|undefined{return (globalThis as any)[bridgeKey]?.input} -export async function delegateRuntime(root:string,hook:string,input:string):Promise { - if((globalThis as any)[bridgeKey])return false; - if(!stableVersion(installedVersion(root)))return false; - // A protected runtime must not execute repository-selected code. - if(protectedRuntime())return false; - let payload:any;try{payload=JSON.parse(input)}catch{return false} - const pkg=pinRuntime(root,payload.session_id ?? ''); - if(!pkg || !/^[a-z-]+$/.test(hook))return false; - const entry=path.join(pkg,'dist','hooks',hook+'.js'); - if(!fs.existsSync(entry))return false; - (globalThis as any)[bridgeKey]={input}; - try{await import(pathToFileURL(entry).href);return true}catch{delete (globalThis as any)[bridgeKey];return false} -} -export function updateNotice(root:string,session:string):string|undefined { - if(!session||updatePolicy(root)==='off'||visibilityMode(root)==='off')return; - const s=updateState(root);if(!stableVersion(s.latest)||!newer(s.latest,installedVersion(root)))return; - if(s.status!=='ready'&&s.status!=='available'&&s.status!=='unsupported')return; - const id=crypto.createHash('sha256').update(s.status+':'+s.latest).digest('hex'); - const file=path.join(updatesDir(root),'notices',id); - try{fs.mkdirSync(path.dirname(file),{recursive:true});fs.writeFileSync(file,'',{flag:'wx',mode:0o600})}catch{return} - if(s.status==='unsupported')return `OpenWolf ${s.latest} is available; this installation needs a runtime migration.`; - return s.status==='ready'?`OpenWolf ${s.latest} is ready for new sessions; this session keeps its pinned runtime.`:`OpenWolf ${s.latest} is available; automatic upgrade is outside this project's update policy.`; -} diff --git a/.opencode/plugin/openwolf/session-state.ts b/.opencode/plugin/openwolf/session-state.ts deleted file mode 100644 index b00eb44..0000000 --- a/.opencode/plugin/openwolf/session-state.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** Complete state at every ingress, including a missed SessionStart (#96 @davdittrich). */ -export function normalizeSession(value: unknown, id: string, now = new Date().toISOString()): Record { - const s: Record = value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {}; - s.session_id = id; - if (typeof s.started !== "string" || !Number.isFinite(Date.parse(s.started))) s.started = now; - for (const key of ["files_read", "edit_counts", "reminders_sent", "injected_by_source"]) { - if (!s[key] || typeof s[key] !== "object" || Array.isArray(s[key])) s[key] = {}; - } - for (const key of ["files_written", "pending_reminders"]) if (!Array.isArray(s[key])) s[key] = []; - for (const key of ["anatomy_hits", "anatomy_misses", "repeated_reads_warned", "stop_count", "cerebrum_warnings"]) { - if (!Number.isSafeInteger(s[key]) || s[key] < 0) s[key] = 0; - } - for (const [file, r] of Object.entries(s.files_read) as Array<[string, any]>) { - if (!r || typeof r !== "object") { delete s.files_read[file]; continue; } - r.count = Number.isSafeInteger(r.count) && r.count >= 0 ? r.count : 0; - r.tokens = Number.isFinite(r.tokens) && r.tokens >= 0 ? r.tokens : 0; - } - s.files_written = s.files_written.filter((w: any) => w && typeof w.file === "string").map((w: any) => ({ ...w, tokens: Number.isFinite(w.tokens) && w.tokens >= 0 ? w.tokens : 0 })); - return s; -} diff --git a/.opencode/plugin/openwolf/session.ts b/.opencode/plugin/openwolf/session.ts deleted file mode 100644 index 927fb9a..0000000 --- a/.opencode/plugin/openwolf/session.ts +++ /dev/null @@ -1,93 +0,0 @@ -import * as fs from "node:fs" -import * as path from "node:path" -import { getWolfDir, writeJSON, readJSON, appendMarkdown, timeShort, timestamp, readMarkdown, sessionFilePath, gcSessionFiles } from "./fs.js" -import type { SessionState } from "./types.js" - -const sessions = new Map() - -export function getSessionState(sessionId: string): SessionState | undefined { - return sessions.get(sessionId) -} - -export function setSessionState(sessionId: string, state: SessionState): void { - sessions.set(sessionId, state) -} - -export function deleteSession(sessionId: string): void { - sessions.delete(sessionId) -} - -export function handleSessionStart(directory: string, sessionId: string): void { - const wolfDir = getWolfDir(directory) - if (!fs.existsSync(wolfDir)) return - - const hooksDir = path.join(wolfDir, "hooks") - fs.mkdirSync(hooksDir, { recursive: true }) - - gcSessionFiles(hooksDir) - - const sessionFile = sessionFilePath(hooksDir, sessionId) - const existing = readJSON(sessionFile, null) - if (existing?.session_id === sessionId) { sessions.set(sessionId, existing); return } - const state: SessionState = { - session_id: sessionId, - started: timestamp(), - files_read: {}, - files_written: [], - edit_counts: {}, - anatomy_hits: 0, - anatomy_misses: 0, - repeated_reads_warned: 0, - cerebrum_warnings: 0, - stop_count: 0, - } - sessions.set(sessionId, state) - writeJSON(sessionFile, state) - - const memoryPath = path.join(wolfDir, "memory.md") - const now = new Date() - const header = `\n## Session: ${now.toISOString().slice(0, 10)} ${timeShort()}\n\n| Time | Action | File(s) | Outcome | ~Tokens |\n|------|--------|---------|---------|--------|\n` - appendMarkdown(memoryPath, header) - - try { - const cerebrumPath = path.join(wolfDir, "cerebrum.md") - const cerebrumContent = fs.readFileSync(cerebrumPath, "utf-8") - const stat = fs.statSync(cerebrumPath) - const daysSinceUpdate = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60 * 24) - const entryLines = cerebrumContent.split("\n").filter(l => { - const t = l.trim() - return t.startsWith("- ") || t.startsWith("* ") || (t.startsWith("[") && t.includes("]")) - }) - if (entryLines.length < 3) { - console.warn(`💡 OpenWolf: cerebrum.md has only ${entryLines.length} entries. Learn from this session — record user preferences, project conventions, and mistakes to .wolf/cerebrum.md.`) - } else if (daysSinceUpdate > 3) { - console.warn(`💡 OpenWolf: cerebrum.md hasn't been updated in ${Math.floor(daysSinceUpdate)} days. Look for opportunities to add learnings this session.`) - } - } catch {} - - try { - const buglogPath = path.join(wolfDir, "buglog.json") - const buglog = readJSON<{ bugs: unknown[] }>(buglogPath, { bugs: [] }) - if (buglog.bugs.length === 0) { - console.warn(`📋 OpenWolf: buglog.json is empty. If you encounter or fix any bugs, errors, or failed tests this session, log them to .wolf/buglog.json.`) - } - } catch {} - - // Count the new session in the lifetime totals. readJSON deep-merges the - // fallback, so a pre-2.0 ledger without `lifetime` still gets the default; - // the guard below covers a ledger where `lifetime` is not an object at all. - const ledgerPath = path.join(wolfDir, "token-ledger.json") - const ledger = readJSON>(ledgerPath, { version: 1, lifetime: { total_sessions: 0 } }) as { - version: number - lifetime: { total_sessions: number } - [key: string]: unknown - } - if (!ledger.lifetime || typeof ledger.lifetime !== "object") { - ledger.lifetime = { total_sessions: 0 } - } - if (typeof ledger.lifetime.total_sessions !== "number" || !isFinite(ledger.lifetime.total_sessions)) { - ledger.lifetime.total_sessions = 0 - } - ledger.lifetime.total_sessions++ - writeJSON(ledgerPath, ledger) -} \ No newline at end of file diff --git a/.opencode/plugin/openwolf/shared.ts b/.opencode/plugin/openwolf/shared.ts deleted file mode 100644 index 7ce420f..0000000 --- a/.opencode/plugin/openwolf/shared.ts +++ /dev/null @@ -1,1005 +0,0 @@ -import {hookReceipt} from './visibility.js'; -import {observeCheckpoint} from "./handoff-state.js"; -import {bridgedInput, delegateRuntime, scheduleUpdate} from "./runtime-updates.js"; -import { normalizeSession } from "./session-state.js"; -export { normalizeSession } from "./session-state.js"; -import { withFileLock, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js"; -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 /.wolf/hooks/.js, which makes the project root -// two directories up, verified by the .wolf/ directory being there. -function projectDirFromScriptLocation(): string | null { - 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(): string { - if (hookCwd) { - let dir = path.resolve(hookCwd); - while (true) { - if (fs.existsSync(path.join(dir, ".wolf"))) return dir; - if (fs.existsSync(path.join(dir, ".git")) || path.dirname(dir) === dir) break; - dir = path.dirname(dir); - } - return hookCwd; - } - return ( - process.env.CLAUDE_PROJECT_DIR || - process.env.CODEX_PROJECT_ROOT || - process.env.GROK_WORKSPACE_ROOT || - process.env.OPENWOLF_PROJECT_ROOT || - projectDirFromScriptLocation() || - process.cwd() - ); -} - -export function getWolfDir(): string { - return path.join(getProjectDir(), ".wolf"); -} - -/** Which agent harness invoked this hook — used for per-agent ledger attribution. */ -export function detectAgent(): string { - if(process.env.GROK_HOOK_EVENT||process.env.GROK_SESSION_ID)return "grok"; - // 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 || process.env.CODEX_THREAD_ID) 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. - * Call this at the top of every hook to avoid crashes in non-OpenWolf projects. - */ -export function ensureWolfDir(): void { - const wolfDir = getWolfDir(); - if (!fs.existsSync(wolfDir)) { - 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"; - -interface HeartbeatEntry { - last_ok?: string; - last_error?: string; - last_error_message?: string; - consecutive_failures: number; -} - -export function recordHeartbeat(hookName: string, error?: unknown): void { - 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: string, fn: () => void | Promise): void { - if (process.argv.includes("--selfcheck")) { - process.stdout.write(`ok ${hookName}`); - process.exit(0); - } - Promise.resolve() - .then(() => readStdin()) - .then(async raw => { - if (hookName === "session-start" || hookName === "user-prompt-submit") scheduleUpdate(getProjectDir()); - try { if (await delegateRuntime(getProjectDir(), hookName, raw)) return true; } catch {} - await fn(); - try { if (["precompact","session-end","stop","user-prompt-submit","post-write","post-bash"].includes(hookName)) observeCheckpoint(getProjectDir(),detectAgent(),hookName,JSON.parse(raw)); } catch {} - if(hookName==="stop")try{const notice=hookReceipt(getProjectDir(),detectAgent(),{...JSON.parse(raw),prompt_id:String(readJSON>(getSessionFilePath(JSON.parse(raw)),{}).stop_count??0)});if(notice)process.stdout.write(JSON.stringify({systemMessage:notice}))}catch{} - return false; - }) - .then(delegated => { - if (delegated) return; - 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: { session_id?: string } | undefined): string { - 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, wolfDir = getWolfDir()): void { - try { - const dir = path.join(wolfDir, "hooks", "sessions"); - const cutoff = Date.now() - maxAgeDays * 24 * 3600 * 1000; - for (const f of fs.readdirSync(dir)) { - if (!f.endsWith(".json")) continue; - try { - const file = path.join(dir, f); - const state = readJSON>(file, {}); - if (state.ended && fs.statSync(file).mtimeMs < cutoff) { - const pending = file + ".events"; - if (fs.existsSync(pending) && fs.readdirSync(pending).length) continue; - if (fs.existsSync(pending)) fs.rmdirSync(pending); - fs.unlinkSync(file); - } - } catch {} - } - } catch {} -} - -export function readJSON(filePath: string, fallback: T): T { - try { - const value = JSON.parse(fs.readFileSync(filePath, "utf-8")); - return (/[/\\]hooks[/\\]sessions[/\\][^/\\]+\.json$/.test(filePath) - ? normalizeSession(value, path.basename(filePath, ".json")) : value) as T; - } catch { - 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: string): { version: number; bugs: any[] } { - 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 as { bugs?: unknown }).bugs; - if (Array.isArray(bugs)) return { ...(raw as object), version: 1, bugs } as { version: number; bugs: any[] }; - } - return { version: 1, bugs: [] }; -} - -export function writeJSON(filePath: string, data: unknown): void { - const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - const tmp = filePath + "." + crypto.randomBytes(4).toString("hex") + ".tmp"; - try { - fs.writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8"); - fs.renameSync(tmp, filePath); - } catch { - // On Windows, rename can fail if another process holds a handle. - // Fall back to direct write and clean up the tmp file. - try { fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8"); } catch {} - try { fs.unlinkSync(tmp); } catch {} - } -} - -export function readMarkdown(filePath: string): string { - try { - return fs.readFileSync(filePath, "utf-8"); - } catch { - return ""; - } -} - -export function appendMarkdown(filePath: string, line: string): void { - const dir = path.dirname(filePath); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - const ok = withFileLock(filePath + ".lock", HOOK_LOCK_BUDGET_MS, () => { fs.appendFileSync(filePath, line, "utf-8"); return true; }); - if (ok === null) { - // Preserve an observation for maintenance to drain; never drop it. - const pending = filePath + ".pending"; - fs.mkdirSync(pending, {recursive:true}); - fs.writeFileSync(path.join(pending, `${Date.now()}-${crypto.randomUUID()}.json`), JSON.stringify({line}), {flag:"wx",mode:0o600}); - } -} - -// 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: string): boolean { - 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: string): string { - const MAX_DESC = 150; - const basename = path.basename(filePath); - const ext = path.extname(basename).toLowerCase(); - const known: Record = { - "package.json": "Node.js package manifest", - "tsconfig.json": "TypeScript configuration", - ".gitignore": "Git ignore rules", - "README.md": "Project documentation", - "composer.json": "PHP package manifest", - "requirements.txt": "Python dependencies", - "schema.sql": "Database schema", - "Dockerfile": "Docker container definition", - "docker-compose.yml": "Docker Compose services", - "Cargo.toml": "Rust package manifest", - "go.mod": "Go module definition", - "Gemfile": "Ruby dependencies", - "pubspec.yaml": "Dart/Flutter package manifest", - }; - if (known[basename]) return known[basename]; - - let content: string; - try { - const fd = fs.openSync(filePath, "r"); - const buf = Buffer.alloc(12288); // 12KB - const n = fs.readSync(fd, buf, 0, 12288, 0); - fs.closeSync(fd); - content = buf.subarray(0, n).toString("utf-8"); - } catch { - return ""; - } - if (!content.trim()) return ""; - - const cap = (s: string) => s.length <= MAX_DESC ? s : s.slice(0, MAX_DESC - 3) + "..."; - - // Markdown heading - if (ext === ".md" || ext === ".mdx") { - const m = content.match(/^#{1,2}\s+(.+)$/m); - if (m) return cap(m[1].trim()); - } - - // HTML title - if (ext === ".html" || ext === ".htm") { - const m = content.match(/]*>([^<]+)<\/title>/i); - if (m) return cap(m[1].trim()); - } - - // JSDoc / PHPDoc / Javadoc — first meaningful line - const jm = content.match(/\/\*\*\s*\n?\s*\*?\s*(.+)/); - if (jm) { - const l = jm[1].replace(/\*\/$/, "").trim(); - if (l && !l.startsWith("@") && l.length > 5) return cap(l); - } - - // Python docstring - if (ext === ".py") { - const dm = content.match(/^(?:#[^\n]*\n)*\s*(?:"""(.+?)"""|'''(.+?)''')/s); - if (dm) { - const first = (dm[1] || dm[2]).split("\n")[0].trim(); - if (first && first.length > 3) return cap(first); - } - } - - // Rust doc comments - if (ext === ".rs") { - const lines = content.split("\n"); - for (const line of lines.slice(0, 20)) { - const m = line.match(/^\s*(?:\/\/\/|\/\/!)\s*(.+)/); - if (m && m[1].length > 5) return cap(m[1].trim()); - } - } - - // Go package comment - if (ext === ".go") { - const m = content.match(/\/\/\s*Package\s+\w+\s+(.*)/); - if (m) return cap(m[1].trim()); - } - - // C# XML doc - if (ext === ".cs") { - const m = content.match(/\s*([\s\S]*?)\s*<\/summary>/); - if (m) { - const text = m[1].replace(/\/\/\/\s*/g, "").replace(/\s+/g, " ").trim(); - if (text.length > 5) return cap(text); - } - } - - // Elixir @moduledoc - if (ext === ".ex" || ext === ".exs") { - const m = content.match(/@moduledoc\s+"""\s*\n\s*(.*)/); - if (m) return cap(m[1].trim()); - } - - // Header comment (skip generic ones) - const hdrLines = content.split("\n"); - for (const line of hdrLines.slice(0, 15)) { - const t = line.trim(); - if (!t || t === " 5 && !lower.startsWith("copyright") && !lower.startsWith("license") && !lower.startsWith("@") && !lower.startsWith("strict") && !lower.startsWith("generated") && !lower.startsWith("eslint-") && !lower.startsWith("nolint")) { - return cap(text); - } - } - if (!t.startsWith("//") && !t.startsWith("#") && !t.startsWith("/*") && !t.startsWith("*") && !t.startsWith("--")) break; - } - - // ─── PHP / Laravel ─────────────────────────────────────── - if (ext === ".php") { - if (basename.endsWith(".blade.php")) { - const ext2 = content.match(/@extends\(\s*['"]([^'"]+)['"]\s*\)/); - const sections = (content.match(/@section\(\s*['"](\w+)['"]/g) || []).map(s => s.match(/['"](\w+)['"]/)?.[1]).filter(Boolean); - const parts: string[] = []; - if (ext2) parts.push(`extends ${ext2[1]}`); - if (sections.length) parts.push(`sections: ${sections.join(", ")}`); - return cap(parts.length ? `Blade: ${parts.join(", ")}` : "Blade template"); - } - - const classM = content.match(/class\s+(\w+)(?:\s+extends\s+(\w+))?/); - const className = classM?.[1] || ""; - const parent = classM?.[2] || ""; - const pubMethods = (content.match(/public\s+function\s+(\w+)/g) || []) - .map(m => m.match(/public\s+function\s+(\w+)/)?.[1]) - .filter(n => n && n !== "__construct" && n !== "middleware") as string[]; - - if (basename.endsWith("Controller.php") || parent === "Controller") { - if (pubMethods.length > 0) { - const display = pubMethods.slice(0, 5).join(", "); - return cap(pubMethods.length > 5 ? `${display} + ${pubMethods.length - 5} more` : display); - } - } - - if (parent === "Model" || parent === "Authenticatable") { - const parts: string[] = []; - const tbl = content.match(/\$table\s*=\s*['"]([^'"]+)['"]/); - if (tbl) parts.push(`table: ${tbl[1]}`); - const fill = content.match(/\$fillable\s*=\s*\[([^\]]*)\]/s); - if (fill) { const c = (fill[1].match(/['"]/g) || []).length / 2; parts.push(`${Math.floor(c)} fields`); } - const rels = (content.match(/\$this->(hasMany|hasOne|belongsTo|belongsToMany|morphMany|morphTo)\(/g) || []).length; - if (rels) parts.push(`${rels} rels`); - return cap(parts.length ? `Model — ${parts.join(", ")}` : `Model: ${className}`); - } - - if (basename.match(/^\d{4}_\d{2}_\d{2}/)) { - const create = content.match(/Schema::create\(\s*['"]([^'"]+)['"]/); - if (create) return `Migration: create ${create[1]} table`; - const alter = content.match(/Schema::table\(\s*['"]([^'"]+)['"]/); - if (alter) return `Migration: alter ${alter[1]} table`; - return "Database migration"; - } - - if (className && pubMethods.length > 0) { - const display = pubMethods.slice(0, 4).join(", "); - return cap(pubMethods.length > 4 ? `${className}: ${display} + ${pubMethods.length - 4} more` : `${className}: ${display}`); - } - } - - // ─── TS/JS/React/Next.js ───────────────────────────────── - if (ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx" || ext === ".mjs" || ext === ".cjs") { - // React component - if (ext === ".tsx" || ext === ".jsx") { - const comp = content.match(/(?:export\s+(?:default\s+)?)?(?:function|const)\s+(\w+)/); - const parts: string[] = []; - if (comp) parts.push(comp[1]); - const renders: string[] = []; - if (/<(?:form|Form)/i.test(content)) renders.push("form"); - if (/<(?:table|Table|DataTable)/i.test(content)) renders.push("table"); - if (/<(?:dialog|Dialog|Modal|Drawer)/i.test(content)) renders.push("modal"); - if (renders.length) parts.push(`renders ${renders.join(", ")}`); - if (parts.length) return cap(parts.join(" — ")); - } - - // Next.js conventions - if (basename === "page.tsx" || basename === "page.js") return "Next.js page component"; - if (basename === "layout.tsx" || basename === "layout.js") return "Next.js layout"; - if (basename === "route.ts" || basename === "route.js") { - const methods = [...new Set((content.match(/export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)/g) || []) - .map(m => m.match(/(GET|POST|PUT|PATCH|DELETE)/)?.[1]))].filter(Boolean); - return methods.length ? `Next.js API route: ${methods.join(", ")}` : "Next.js API route"; - } - - // Express/Fastify routes - const routeHits = content.match(/\.(get|post|put|patch|delete)\s*\(\s*['"`]/g); - if (routeHits && routeHits.length > 0) { - const methods = [...new Set(routeHits.map(r => r.match(/\.(get|post|put|patch|delete)/)?.[1]?.toUpperCase()))]; - return cap(`API routes: ${methods.join(", ")} (${routeHits.length} endpoints)`); - } - - // tRPC router - if (content.includes("createTRPCRouter") || content.includes("publicProcedure")) { - const procs = (content.match(/\.(query|mutation|subscription)\s*\(/g) || []).length; - return procs ? `tRPC router: ${procs} procedures` : "tRPC router"; - } - - // Zod schemas - if (content.includes("z.object") || content.includes("z.string")) { - const schemas = (content.match(/(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*z\./g) || []) - .map(s => s.match(/(?:const|let)\s+(\w+)/)?.[1]).filter(Boolean); - if (schemas.length) return cap(`Zod schemas: ${schemas.slice(0, 4).join(", ")}${schemas.length > 4 ? ` + ${schemas.length - 4} more` : ""}`); - } - - // Exports summary - const exports = (content.match(/export\s+(?:async\s+)?(?:function|class|const|interface|type|enum)\s+(\w+)/g) || []) - .map(e => e.match(/(\w+)$/)?.[1]).filter(Boolean) as string[]; - if (exports.length > 0 && exports.length <= 5) return `Exports ${exports.join(", ")}`; - if (exports.length > 5) return cap(`Exports ${exports.slice(0, 4).join(", ")} + ${exports.length - 4} more`); - } - - // ─── Python / Django / FastAPI / Flask ──────────────────── - if (ext === ".py") { - // Django model - if (content.includes("models.Model")) { - const cls = content.match(/class\s+(\w+)\(.*models\.Model\)/); - const fields = (content.match(/^\s+\w+\s*=\s*models\.\w+/gm) || []).length; - return cap(`Model: ${cls?.[1] || "unknown"}, ${fields} fields`); - } - // FastAPI/Flask routes - if (content.includes("@router.") || content.includes("@app.")) { - const routes = (content.match(/@(?:router|app)\.(get|post|put|patch|delete)\s*\(/g) || []); - return cap(routes.length ? `API: ${routes.length} endpoints` : "API router"); - } - // Pydantic - if (content.includes("BaseModel") && content.includes("Field(")) { - const cls = content.match(/class\s+(\w+)\(.*BaseModel\)/); - return cls ? `Pydantic: ${cls[1]}` : "Pydantic model"; - } - // Celery - if (content.includes("@shared_task") || content.includes("@app.task")) { - const tasks = (content.match(/def\s+(\w+)/g) || []).map(m => m.match(/def\s+(\w+)/)?.[1]).filter(n => n && !n.startsWith("_")) as string[]; - return cap(tasks.length ? `Celery tasks: ${tasks.join(", ")}` : "Celery task"); - } - // Generic - const pyClass = content.match(/class\s+(\w+)/); - const funcs = (content.match(/def\s+(\w+)/g) || []).map(f => f.match(/def\s+(\w+)/)?.[1]).filter(n => n && !n.startsWith("_")) as string[]; - if (pyClass && funcs.length > 0) return cap(funcs.length > 4 ? `${pyClass[1]}: ${funcs.slice(0, 4).join(", ")} + ${funcs.length - 4} more` : `${pyClass[1]}: ${funcs.join(", ")}`); - if (funcs.length > 0) return cap(funcs.slice(0, 4).join(", ")); - } - - // ─── Go ────────────────────────────────────────────────── - if (ext === ".go") { - const handlers = (content.match(/func\s+(\w+)\s*\(\s*\w+\s+http\.ResponseWriter/g) || []) - .map(m => m.match(/func\s+(\w+)/)?.[1]).filter(Boolean); - if (handlers.length) return cap(`HTTP handlers: ${handlers.slice(0, 5).join(", ")}`); - const iface = content.match(/type\s+(\w+)\s+interface\s*\{/); - if (iface) return `Interface: ${iface[1]}`; - const structM = content.match(/type\s+(\w+)\s+struct\s*\{/); - if (structM) return `Struct: ${structM[1]}`; - const funcs = (content.match(/^func\s+(\w+)/gm) || []).map(m => m.match(/func\s+(\w+)/)?.[1]).filter(n => n && n[0] === n[0].toUpperCase()) as string[]; - if (funcs.length) return cap(funcs.slice(0, 5).join(", ")); - } - - // ─── Rust ──────────────────────────────────────────────── - if (ext === ".rs") { - const structM = content.match(/pub\s+struct\s+(\w+)/); - if (structM) { - const methods = (content.match(/pub\s+(?:async\s+)?fn\s+(\w+)/g) || []).map(m => m.match(/fn\s+(\w+)/)?.[1]).filter(Boolean); - return cap(methods.length ? `${structM[1]}: ${methods.slice(0, 4).join(", ")}` : `Struct: ${structM[1]}`); - } - const traitM = content.match(/pub\s+trait\s+(\w+)/); - if (traitM) return `Trait: ${traitM[1]}`; - const enumM = content.match(/pub\s+enum\s+(\w+)/); - if (enumM) return `Enum: ${enumM[1]}`; - const fns = (content.match(/pub\s+(?:async\s+)?fn\s+(\w+)/g) || []).map(m => m.match(/fn\s+(\w+)/)?.[1]).filter(Boolean); - if (fns.length) return cap(fns.slice(0, 5).join(", ")); - } - - // ─── Java / Spring ─────────────────────────────────────── - if (ext === ".java") { - const cls = content.match(/(?:public\s+)?class\s+(\w+)/); - const className = cls?.[1] || basename.replace(".java", ""); - const annotations = (content.match(/@(RestController|Controller|Service|Repository|Component|Entity|Configuration)/g) || []).map(a => a.slice(1)); - const mappings = (content.match(/@(?:Get|Post|Put|Patch|Delete|Request)Mapping/g) || []).length; - if (mappings) return cap(`${annotations[0] || "Spring"}: ${className} (${mappings} endpoints)`); - if (annotations.length) return `${annotations[0]}: ${className}`; - if (content.includes("@Entity")) return `Entity: ${className}`; - const methods = (content.match(/public\s+(?:static\s+)?(?:\w+(?:<[\w,\s]+>)?)\s+(\w+)\s*\(/g) || []) - .map(m => m.match(/(\w+)\s*\(/)?.[1]).filter(n => n && n !== className) as string[]; - if (methods.length) return cap(`${className}: ${methods.slice(0, 4).join(", ")}`); - return className ? `Class: ${className}` : ""; - } - - // ─── Kotlin ────────────────────────────────────────────── - if (ext === ".kt" || ext === ".kts") { - const cls = content.match(/(?:data\s+)?class\s+(\w+)/); - if (content.match(/data\s+class/)) return `Data class: ${cls?.[1] || basename.replace(/\.kts?$/, "")}`; - if (content.includes("routing {")) return "Ktor routing"; - const fns = (content.match(/fun\s+(\w+)/g) || []).map(m => m.match(/fun\s+(\w+)/)?.[1]).filter(Boolean); - if (cls && fns.length) return cap(`${cls[1]}: ${fns.slice(0, 4).join(", ")}`); - if (fns.length) return cap(fns.slice(0, 5).join(", ")); - } - - // ─── C# / .NET ─────────────────────────────────────────── - if (ext === ".cs") { - const cls = content.match(/(?:public\s+)?(?:partial\s+)?class\s+(\w+)(?:\s*:\s*(\w+))?/); - const className = cls?.[1] || basename.replace(".cs", ""); - const parent = cls?.[2] || ""; - if (parent === "Controller" || parent === "ControllerBase" || content.includes("[ApiController]")) { - const actions = (content.match(/\[Http(Get|Post|Put|Patch|Delete)\]/g) || []).map(a => a.match(/Http(\w+)/)?.[1]).filter(Boolean); - return cap(actions.length ? `API Controller: ${className} (${[...new Set(actions)].join(", ")})` : `Controller: ${className}`); - } - if (parent === "DbContext" || content.includes("DbSet<")) { - const sets = (content.match(/DbSet<(\w+)>/g) || []).map(s => s.match(/<(\w+)>/)?.[1]).filter(Boolean); - return cap(sets.length ? `DbContext: ${sets.join(", ")}` : `DbContext: ${className}`); - } - return className ? `Class: ${className}` : ""; - } - - // ─── Ruby / Rails ──────────────────────────────────────── - if (ext === ".rb") { - const cls = content.match(/class\s+(\w+)(?:\s*<\s*(\w+(?:::\w+)?))?/); - const className = cls?.[1] || ""; - const parent = cls?.[2] || ""; - if (parent?.includes("Controller")) { - const actions = (content.match(/def\s+(index|show|new|create|edit|update|destroy|\w+)/g) || []) - .map(m => m.match(/def\s+(\w+)/)?.[1]).filter(n => n && !n.startsWith("_")) as string[]; - return cap(actions.length ? `Controller: ${actions.join(", ")}` : `Controller: ${className}`); - } - if (parent === "ApplicationRecord" || parent === "ActiveRecord::Base") return `Model: ${className}`; - if (basename.match(/^\d{14}_/)) { - const create = content.match(/create_table\s+:(\w+)/); - return create ? `Migration: create ${create[1]}` : "Database migration"; - } - const methods = (content.match(/def\s+(\w+)/g) || []).map(m => m.match(/def\s+(\w+)/)?.[1]).filter(n => n && !n.startsWith("_")) as string[]; - if (cls && methods.length) return cap(`${className}: ${methods.slice(0, 4).join(", ")}`); - } - - // ─── Swift ─────────────────────────────────────────────── - if (ext === ".swift") { - if (content.includes(": View") || content.includes("some View")) { - const name = content.match(/struct\s+(\w+)\s*:\s*View/); - return name ? `SwiftUI view: ${name[1]}` : "SwiftUI view"; - } - const proto = content.match(/protocol\s+(\w+)/); - if (proto) return `Protocol: ${proto[1]}`; - const struct = content.match(/(?:public\s+)?struct\s+(\w+)/); - const cls = content.match(/(?:public\s+)?class\s+(\w+)/); - const name = struct?.[1] || cls?.[1] || ""; - if (name) return `${struct ? "Struct" : "Class"}: ${name}`; - } - - // ─── Dart / Flutter ────────────────────────────────────── - if (ext === ".dart") { - if (content.includes("StatefulWidget") || content.includes("StatelessWidget")) { - const name = content.match(/class\s+(\w+)\s+extends\s+(?:Stateful|Stateless)Widget/); - return name ? `${content.includes("StatefulWidget") ? "Stateful" : "Stateless"} widget: ${name[1]}` : "Flutter widget"; - } - const cls = content.match(/class\s+(\w+)/); - if (cls) return `Class: ${cls[1]}`; - } - - // ─── Vue / Svelte / Astro ──────────────────────────────── - if (ext === ".vue") { - const name = content.match(/name:\s*['"]([^'"]+)['"]/); - const setup = content.includes("