From 23bfabb9181ab1e8bdcc5dda3c05e48c031dfdec Mon Sep 17 00:00:00 2001 From: nottyjay Date: Thu, 7 May 2026 13:27:27 +0800 Subject: [PATCH 1/9] feat: add codex hook integration --- README.md | 29 +++- src/cli/index.ts | 8 +- src/cli/init.test.ts | 122 +++++++++++++ src/cli/init.ts | 269 +++++++++++++++++++++++------ src/cli/status.ts | 30 +++- src/cli/update.ts | 158 +++++++++++++---- src/hooks/post-read.ts | 4 +- src/hooks/post-write.ts | 4 +- src/hooks/pre-read.ts | 4 +- src/hooks/shared.ts | 8 +- src/templates/agents-md-snippet.md | 5 + 11 files changed, 529 insertions(+), 112 deletions(-) create mode 100644 src/cli/init.test.ts create mode 100644 src/templates/agents-md-snippet.md diff --git a/README.md b/README.md index c19756e4..37e8faf1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

OpenWolf

- A second brain for Claude Code.
+ A second brain for Claude Code and Codex.
Project intelligence, token tracking, and invisible enforcement through 6 hook scripts. Zero workflow changes.

@@ -45,11 +45,18 @@ cd your-project openwolf init ``` -That's it. Use `claude` normally. OpenWolf is watching. +By default, `openwolf init` installs both Claude and Codex integration. + +```bash +openwolf init claude # only Claude integration +openwolf init codex # only Codex integration +``` + +That's it. Use `claude` or `codex` normally. OpenWolf is watching. ## What It Creates -`openwolf init` creates a `.wolf/` directory in your project: +`openwolf init` creates a `.wolf/` directory in your project and installs agent-specific entry files: | File | Purpose | |------|---------| @@ -58,10 +65,15 @@ That's it. Use `claude` normally. OpenWolf is watching. | `memory.md` | Chronological action log with token estimates | | `buglog.json` | Bug fix memory, searchable, prevents re-discovery | | `token-ledger.json` | Lifetime token tracking and session history | -| `hooks/` | 6 Claude Code lifecycle hooks (pure Node.js) | +| `hooks/` | Provider-specific hook scripts under `.wolf/hooks/claude/` and `.wolf/hooks/codex/` | | `config.json` | Configuration with sensible defaults | | `identity.md` | Agent persona for this project | -| `OPENWOLF.md` | Instructions Claude follows every session | +| `OPENWOLF.md` | Shared OpenWolf operating protocol | +| `CLAUDE.md` | Claude entry file that points Claude at `.wolf/OPENWOLF.md` | +| `AGENTS.md` | Codex entry file that points Codex at `.wolf/OPENWOLF.md` | + +When Claude integration is enabled, OpenWolf also writes `.claude/settings.json` and `.claude/rules/openwolf.md` to register hooks and rules. +When Codex integration is enabled, OpenWolf writes `.codex/hooks.json` and `.codex/config.toml` to register Codex hooks. ## How It Works @@ -178,7 +190,7 @@ Before fixing anything, Claude checks if the fix is already known. After fixing, ## Commands ``` -openwolf init Initialize .wolf/ and register hooks +openwolf init [target] Initialize .wolf/ and install Claude/Codex integration openwolf status Show health, stats, file integrity openwolf scan Refresh the project structure map openwolf scan --check Verify anatomy matches filesystem (exits 1 if stale) @@ -212,12 +224,12 @@ Ask Claude to help you pick a UI framework. OpenWolf ships a curated knowledge b ## How OpenWolf Compares -OpenWolf is not an AI wrapper. It is 6 hook scripts and a `.wolf/` directory. It doesn't run your AI for you or change your workflow. It gives Claude Code what it lacks: a project map so it reads less, a memory so it learns faster, and a ledger so you see where tokens go. +OpenWolf is not an AI wrapper. It is 6 hook scripts and a `.wolf/` directory. It doesn't run your AI for you or change your workflow. It gives Claude Code and Codex what they lack: a project map so they read less, a memory so they learn faster, and a ledger so you see where tokens go. ## Requirements - Node.js 20+ -- Claude Code CLI +- Claude Code CLI or Codex - Windows, macOS, or Linux - Optional: PM2 for persistent background tasks - Optional: `puppeteer-core` for Design QC screenshots @@ -225,6 +237,7 @@ OpenWolf is not an AI wrapper. It is 6 hook scripts and a `.wolf/` directory. It ## Limitations - Claude Code hooks are a relatively new feature. OpenWolf falls back to `CLAUDE.md` instructions when hooks don't fire. +- Codex hooks require Codex project hook support via `.codex/hooks.json` and `.codex/config.toml`, plus `AGENTS.md` project instructions. - Token tracking is estimation-based (character-to-token ratio), not exact API counts. Accurate to within ~15%. - `cerebrum.md` depends on Claude following instructions to update it after corrections. Compliance is ~85-90%, not 100%. - This is v1.0.4. Things may break. [File issues](https://github.com/cytostack/openwolf/issues). diff --git a/src/cli/index.ts b/src/cli/index.ts index c2bbf5f1..db70563a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -29,9 +29,11 @@ export function createProgram(): Command { .version(getVersion()); program - .command("init") - .description("Initialize .wolf/ in current project") - .action(initCommand); + .command("init [target]") + .description("Initialize .wolf/ and install Claude/Codex integration in current project") + .action(async (target?: string) => { + await initCommand(target); + }); program .command("status") diff --git a/src/cli/init.test.ts b/src/cli/init.test.ts new file mode 100644 index 00000000..1988d8bf --- /dev/null +++ b/src/cli/init.test.ts @@ -0,0 +1,122 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { initCommand } from "./init.js"; + +const runInit = initCommand as unknown as (target?: string) => Promise; + +function makeProject(name: string): string { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), `openwolf-${name}-`)); + fs.writeFileSync( + path.join(projectRoot, "package.json"), + JSON.stringify({ name, version: "1.0.0" }, null, 2), + "utf-8" + ); + fs.writeFileSync( + path.join(projectRoot, "index.ts"), + "export const value = 1;\n", + "utf-8" + ); + return projectRoot; +} + +function read(filePath: string): string { + return fs.readFileSync(filePath, "utf-8"); +} + +function readJSON(filePath: string): Record { + return JSON.parse(read(filePath)) as Record; +} + +test("default init installs both Claude and Codex integration files", async () => { + const projectRoot = makeProject("dual-target"); + const previousCwd = process.cwd(); + const previousHome = process.env.HOME; + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "openwolf-home-")); + + process.chdir(projectRoot); + process.env.HOME = fakeHome; + + try { + await runInit(); + + assert.ok(fs.existsSync(path.join(projectRoot, ".wolf", "OPENWOLF.md"))); + assert.ok(fs.existsSync(path.join(projectRoot, "CLAUDE.md"))); + assert.ok(fs.existsSync(path.join(projectRoot, ".claude", "settings.json"))); + assert.ok(fs.existsSync(path.join(projectRoot, ".claude", "rules", "openwolf.md"))); + assert.ok(fs.existsSync(path.join(projectRoot, "AGENTS.md"))); + assert.ok(fs.existsSync(path.join(projectRoot, ".codex", "hooks.json"))); + assert.ok(fs.existsSync(path.join(projectRoot, ".codex", "config.toml"))); + assert.ok(fs.existsSync(path.join(projectRoot, ".wolf", "hooks", "claude", "session-start.js"))); + assert.ok(fs.existsSync(path.join(projectRoot, ".wolf", "hooks", "codex", "session-start.js"))); + assert.equal(fs.existsSync(path.join(projectRoot, ".wolf", "hooks", "session-start.js")), false); + + assert.match(read(path.join(projectRoot, "CLAUDE.md")), /@\.wolf\/OPENWOLF\.md/); + assert.match(read(path.join(projectRoot, "AGENTS.md")), /@\.wolf\/OPENWOLF\.md/); + assert.match( + JSON.stringify(readJSON(path.join(projectRoot, ".codex", "hooks.json"))), + /\.wolf\/hooks\/codex\/session-start\.js/ + ); + } finally { + process.chdir(previousCwd); + process.env.HOME = previousHome; + fs.rmSync(projectRoot, { recursive: true, force: true }); + fs.rmSync(fakeHome, { recursive: true, force: true }); + } +}); + +test("claude target only installs Claude integration files", async () => { + const projectRoot = makeProject("claude-only"); + const previousCwd = process.cwd(); + const previousHome = process.env.HOME; + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "openwolf-home-")); + + process.chdir(projectRoot); + process.env.HOME = fakeHome; + + try { + await runInit("claude"); + + assert.ok(fs.existsSync(path.join(projectRoot, "CLAUDE.md"))); + assert.ok(fs.existsSync(path.join(projectRoot, ".claude", "settings.json"))); + assert.ok(fs.existsSync(path.join(projectRoot, ".wolf", "hooks", "claude", "session-start.js"))); + assert.equal(fs.existsSync(path.join(projectRoot, ".codex", "hooks.json")), false); + assert.equal(fs.existsSync(path.join(projectRoot, ".codex", "config.toml")), false); + assert.equal(fs.existsSync(path.join(projectRoot, ".wolf", "hooks", "codex", "session-start.js")), false); + assert.equal(fs.existsSync(path.join(projectRoot, "AGENTS.md")), false); + } finally { + process.chdir(previousCwd); + process.env.HOME = previousHome; + fs.rmSync(projectRoot, { recursive: true, force: true }); + fs.rmSync(fakeHome, { recursive: true, force: true }); + } +}); + +test("codex target only installs Codex integration files", async () => { + const projectRoot = makeProject("codex-only"); + const previousCwd = process.cwd(); + const previousHome = process.env.HOME; + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "openwolf-home-")); + + process.chdir(projectRoot); + process.env.HOME = fakeHome; + + try { + await runInit("codex"); + + assert.ok(fs.existsSync(path.join(projectRoot, "AGENTS.md"))); + assert.ok(fs.existsSync(path.join(projectRoot, ".codex", "hooks.json"))); + assert.ok(fs.existsSync(path.join(projectRoot, ".codex", "config.toml"))); + assert.ok(fs.existsSync(path.join(projectRoot, ".wolf", "hooks", "codex", "session-start.js"))); + assert.equal(fs.existsSync(path.join(projectRoot, ".wolf", "hooks", "claude", "session-start.js")), false); + assert.equal(fs.existsSync(path.join(projectRoot, "CLAUDE.md")), false); + assert.equal(fs.existsSync(path.join(projectRoot, ".claude", "settings.json")), false); + } finally { + process.chdir(previousCwd); + process.env.HOME = previousHome; + fs.rmSync(projectRoot, { recursive: true, force: true }); + fs.rmSync(fakeHome, { recursive: true, force: true }); + } +}); diff --git a/src/cli/init.ts b/src/cli/init.ts index 0414bb75..3c464fba 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -44,8 +44,10 @@ const CREATE_IF_MISSING = [ "suggestions.json", ]; +type InitTarget = "claude" | "codex"; + // Use $CLAUDE_PROJECT_DIR so hooks resolve correctly even if CWD changes during a session -const HOOK_SETTINGS = { +const CLAUDE_HOOK_SETTINGS = { hooks: { SessionStart: [ { @@ -53,7 +55,7 @@ const HOOK_SETTINGS = { hooks: [ { type: "command", - command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/session-start.js"', + command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/session-start.js"', timeout: 5, }, ], @@ -65,7 +67,7 @@ const HOOK_SETTINGS = { hooks: [ { type: "command", - command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-read.js"', + command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/pre-read.js"', timeout: 5, }, ], @@ -75,7 +77,7 @@ const HOOK_SETTINGS = { hooks: [ { type: "command", - command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-write.js"', + command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/pre-write.js"', timeout: 5, }, ], @@ -87,7 +89,7 @@ const HOOK_SETTINGS = { hooks: [ { type: "command", - command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-read.js"', + command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/post-read.js"', timeout: 5, }, ], @@ -97,7 +99,7 @@ const HOOK_SETTINGS = { hooks: [ { type: "command", - command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js"', + command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/post-write.js"', timeout: 10, }, ], @@ -109,8 +111,86 @@ const HOOK_SETTINGS = { hooks: [ { type: "command", - command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/stop.js"', + command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/stop.js"', + timeout: 10, + }, + ], + }, + ], + }, +}; + +const CODEX_HOOK_SETTINGS = { + hooks: { + SessionStart: [ + { + matcher: "startup|resume|clear", + hooks: [ + { + type: "command", + command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/session-start.js"', + timeout: 5, + statusMessage: "OpenWolf session bootstrap", + }, + ], + }, + ], + PreToolUse: [ + { + matcher: "Read", + hooks: [ + { + type: "command", + command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/pre-read.js"', + timeout: 5, + statusMessage: "OpenWolf read precheck", + }, + ], + }, + { + matcher: "Edit|Write|MultiEdit|apply_patch", + hooks: [ + { + type: "command", + command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/pre-write.js"', + timeout: 5, + statusMessage: "OpenWolf write precheck", + }, + ], + }, + ], + PostToolUse: [ + { + matcher: "Read", + hooks: [ + { + type: "command", + command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/post-read.js"', + timeout: 5, + statusMessage: "OpenWolf read tracking", + }, + ], + }, + { + matcher: "Edit|Write|MultiEdit|apply_patch", + hooks: [ + { + type: "command", + command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/post-write.js"', + timeout: 10, + statusMessage: "OpenWolf write tracking", + }, + ], + }, + ], + Stop: [ + { + hooks: [ + { + type: "command", + command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/stop.js"', timeout: 10, + statusMessage: "OpenWolf session finalize", }, ], }, @@ -118,7 +198,11 @@ const HOOK_SETTINGS = { }, }; -export async function initCommand(): Promise { +const CODEX_CONFIG_TOML = `[features] +codex_hooks = true +`; + +export async function initCommand(targetArg?: string): Promise { // Check Node.js version const nodeVersion = parseInt(process.version.slice(1), 10); if (nodeVersion < 20) { @@ -126,6 +210,8 @@ export async function initCommand(): Promise { process.exit(1); } + const targets = resolveInitTargets(targetArg); + // Detect project root const projectRoot = findProjectRoot(); console.log(`Project root: ${projectRoot}`); @@ -180,37 +266,13 @@ export async function initCommand(): Promise { } // --- Hook scripts: always update (bug fixes, new features) --- - copyHookScripts(wolfDir); - - // --- Claude settings: replace OpenWolf hooks (upgrade old paths) --- - const claudeDir = path.join(projectRoot, ".claude"); - ensureDir(claudeDir); + copyHookScripts(wolfDir, targets); - const settingsPath = path.join(claudeDir, "settings.json"); - if (fs.existsSync(settingsPath)) { - const existing = readJSON>(settingsPath, {}); - const merged = replaceOpenWolfHooks(existing, HOOK_SETTINGS); - writeJSON(settingsPath, merged); - } else { - writeJSON(settingsPath, HOOK_SETTINGS); + if (targets.includes("claude")) { + installClaudeIntegration(projectRoot, actualTemplatesDir); } - - // --- Claude rules: always update --- - const rulesDir = path.join(claudeDir, "rules"); - ensureDir(rulesDir); - const rulesContent = readTemplateContent("claude-rules-openwolf.md", actualTemplatesDir); - writeText(path.join(rulesDir, "openwolf.md"), rulesContent); - - // --- CLAUDE.md: add snippet if missing --- - const claudeMdPath = path.join(projectRoot, "CLAUDE.md"); - const snippetContent = readTemplateContent("claude-md-snippet.md", actualTemplatesDir); - if (fs.existsSync(claudeMdPath)) { - const existing = readText(claudeMdPath); - if (!existing.includes("OpenWolf")) { - writeText(claudeMdPath, snippetContent + "\n\n" + existing); - } - } else { - writeText(claudeMdPath, snippetContent); + if (targets.includes("codex")) { + installCodexIntegration(projectRoot, actualTemplatesDir); } // --- Anatomy scan: only on fresh init --- @@ -273,18 +335,30 @@ export async function initCommand(): Promise { console.log(` ✓ All .wolf data preserved (${skippedCount} files: cerebrum, memory, anatomy, buglog, ledger)`); console.log(` ✓ Hook scripts updated (6 hooks)`); console.log(` ✓ ${createdCount} config files updated`); + if (targets.includes("claude")) { + console.log(` ✓ Claude integration refreshed`); + } + if (targets.includes("codex")) { + console.log(` ✓ Codex integration refreshed`); + } console.log(` ✓ Anatomy: ${fileCount} files tracked (unchanged)`); } else { console.log(` ✓ OpenWolf v${version} initialized`); console.log(` ✓ .wolf/ created with ${createdCount} files`); - console.log(` ✓ Claude Code hooks registered (6 hooks)`); - console.log(` ✓ CLAUDE.md updated`); - console.log(` ✓ .claude/rules/openwolf.md created`); + if (targets.includes("claude")) { + console.log(` ✓ Claude Code hooks registered (6 hooks)`); + console.log(` ✓ CLAUDE.md updated`); + console.log(` ✓ .claude/rules/openwolf.md created`); + } + if (targets.includes("codex")) { + console.log(` ✓ Codex hooks registered (.codex/hooks.json)`); + console.log(` ✓ AGENTS.md updated for Codex`); + } console.log(` ✓ Anatomy scan: ${fileCount} files indexed`); } console.log(` ✓ Daemon: ${daemonStatus}`); console.log(""); - console.log(" You're ready. Just use 'claude' as normal — OpenWolf is watching."); + console.log(` You're ready. Just use '${formatTargetsForHumans(targets)}' as normal — OpenWolf is watching.`); console.log(""); } @@ -303,6 +377,52 @@ function findTemplatesDir(): string { return candidates[0]; // fallback — generateTemplate will handle missing files } +function resolveInitTargets(targetArg?: string): InitTarget[] { + if (!targetArg) return ["claude", "codex"]; + if (targetArg === "claude" || targetArg === "codex") { + return [targetArg]; + } + + console.error(`Unknown init target: ${targetArg}`); + console.error("Valid targets: claude, codex"); + process.exit(1); +} + +function installClaudeIntegration(projectRoot: string, templatesDir: string): void { + const claudeDir = path.join(projectRoot, ".claude"); + ensureDir(claudeDir); + + const settingsPath = path.join(claudeDir, "settings.json"); + if (fs.existsSync(settingsPath)) { + const existing = readJSON>(settingsPath, {}); + const merged = replaceOpenWolfHooks(existing, CLAUDE_HOOK_SETTINGS); + writeJSON(settingsPath, merged); + } else { + writeJSON(settingsPath, CLAUDE_HOOK_SETTINGS); + } + + const rulesDir = path.join(claudeDir, "rules"); + ensureDir(rulesDir); + const rulesContent = readTemplateContent("claude-rules-openwolf.md", templatesDir); + writeText(path.join(rulesDir, "openwolf.md"), rulesContent); + + const claudeMdPath = path.join(projectRoot, "CLAUDE.md"); + const snippetContent = readTemplateContent("claude-md-snippet.md", templatesDir); + prependSnippetIfMissing(claudeMdPath, snippetContent); +} + +function installCodexIntegration(projectRoot: string, templatesDir: string): void { + const codexDir = path.join(projectRoot, ".codex"); + ensureDir(codexDir); + + writeJSON(path.join(codexDir, "hooks.json"), CODEX_HOOK_SETTINGS); + writeText(path.join(codexDir, "config.toml"), CODEX_CONFIG_TOML); + + const agentsPath = path.join(projectRoot, "AGENTS.md"); + const snippetContent = readTemplateContent("agents-md-snippet.md", templatesDir); + prependSnippetIfMissing(agentsPath, snippetContent); +} + function writeTemplateFile(templatesDir: string, wolfDir: string, file: string): void { const srcPath = path.join(templatesDir, file); const destPath = path.join(wolfDir, file); @@ -324,11 +444,29 @@ function readTemplateContent(filename: string, templatesDir: string): string { function getEmbeddedTemplate(filename: string): string { const templates: Record = { "claude-md-snippet.md": `# OpenWolf\n\n@.wolf/OPENWOLF.md\n\nThis project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md every session. Check .wolf/cerebrum.md before generating code. Check .wolf/anatomy.md before reading files.`, + "agents-md-snippet.md": `# OpenWolf\n\n@.wolf/OPENWOLF.md\n\nThis project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md every session. Check .wolf/cerebrum.md before generating code. Check .wolf/anatomy.md before reading files.`, "claude-rules-openwolf.md": `---\ndescription: OpenWolf protocol enforcement — active on all files\nglobs: **/*\n---\n\n- Check .wolf/anatomy.md before reading any project file\n- Check .wolf/cerebrum.md Do-Not-Repeat list before generating code\n- After writing or editing files, update .wolf/anatomy.md and append to .wolf/memory.md\n- After receiving a user correction, update .wolf/cerebrum.md immediately (Preferences, Learnings, or Do-Not-Repeat)\n- LEARN from every interaction: if you discover a convention, user preference, or project pattern, add it to .wolf/cerebrum.md. Low threshold — when in doubt, log it.\n- BEFORE fixing any bug or error: read .wolf/buglog.json for known fixes\n- AFTER fixing any bug, error, failed test, failed build, or user-reported problem: ALWAYS log to .wolf/buglog.json with error_message, root_cause, fix, and tags\n- If you edit a file more than twice in a session, that likely indicates a bug — log it to .wolf/buglog.json\n- When the user asks to check/evaluate UI design: run \`openwolf designqc\` to capture screenshots, then read them from .wolf/designqc-captures/\n- When the user asks to change/pick/migrate UI framework: read .wolf/reframe-frameworks.md, ask decision questions, recommend a framework, then execute with the framework's prompt`, }; return templates[filename] ?? ""; } +function prependSnippetIfMissing(filePath: string, snippetContent: string): void { + if (fs.existsSync(filePath)) { + const existing = readText(filePath); + if (!existing.includes("OpenWolf")) { + writeText(filePath, snippetContent + "\n\n" + existing); + } + return; + } + + writeText(filePath, snippetContent); +} + +function formatTargetsForHumans(targets: InitTarget[]): string { + if (targets.length === 2) return "claude' or 'codex"; + return targets[0]; +} + function generateTemplate(destPath: string, file: string): void { const templates: Record = { "OPENWOLF.md": `# OpenWolf Operating Protocol\n\nYou are working in an OpenWolf-managed project. These rules apply every turn.\n\n## File Navigation\n\n1. Check \`.wolf/anatomy.md\` BEFORE reading any file.\n2. If the description is sufficient, do NOT read the full file.\n3. If a file is not in anatomy.md, search with Grep/Glob.\n\n## Code Generation\n\n1. Read \`.wolf/cerebrum.md\` and respect every entry.\n2. Check \`## Do-Not-Repeat\` section.\n\n## After Actions\n\n1. Append to \`.wolf/memory.md\`.\n2. After file changes: update \`.wolf/anatomy.md\`.\n\n## Token Discipline\n\n- Never re-read a file already read this session.\n- Prefer anatomy.md descriptions over full reads.\n`, @@ -404,7 +542,7 @@ function seedIdentity(wolfDir: string, projectRoot: string): void { writeText(identityPath, content); } -function copyHookScripts(wolfDir: string): void { +function copyHookScripts(wolfDir: string, targets: InitTarget[]): void { const hooksDir = path.join(wolfDir, "hooks"); ensureDir(hooksDir); @@ -437,23 +575,33 @@ function copyHookScripts(wolfDir: string): void { let copiedAny = false; if (sourceDir) { - for (const file of hookFiles) { - const src = path.join(sourceDir, file); - if (fs.existsSync(src)) { - fs.copyFileSync(src, path.join(hooksDir, file)); - copiedAny = true; + removeLegacyTopLevelHooks(hooksDir, hookFiles); + for (const target of targets) { + const providerDir = path.join(hooksDir, target); + ensureDir(providerDir); + for (const file of hookFiles) { + const src = path.join(sourceDir, file); + if (fs.existsSync(src)) { + fs.copyFileSync(src, path.join(providerDir, file)); + copiedAny = true; + } } } } else if (fs.existsSync(srcHooksDir)) { // Dev mode: compile TS hooks inline using a simple copy with note // In practice, user should run `pnpm build:hooks` first - for (const file of hookFiles) { - const tsFile = file.replace(".js", ".ts"); - const src = path.join(srcHooksDir, tsFile); - if (fs.existsSync(src)) { - const loaderContent = `#!/usr/bin/env node\n// Auto-generated by openwolf init — run 'pnpm build:hooks' for compiled version\nimport("${src.replace(/\\/g, "/")}");\n`; - fs.writeFileSync(path.join(hooksDir, file), loaderContent, "utf-8"); - copiedAny = true; + removeLegacyTopLevelHooks(hooksDir, hookFiles); + for (const target of targets) { + const providerDir = path.join(hooksDir, target); + ensureDir(providerDir); + for (const file of hookFiles) { + const tsFile = file.replace(".js", ".ts"); + const src = path.join(srcHooksDir, tsFile); + if (fs.existsSync(src)) { + const loaderContent = `#!/usr/bin/env node\n// Auto-generated by openwolf init — run 'pnpm build:hooks' for compiled version\nimport("${src.replace(/\\/g, "/")}");\n`; + fs.writeFileSync(path.join(providerDir, file), loaderContent, "utf-8"); + copiedAny = true; + } } } } @@ -467,6 +615,17 @@ function copyHookScripts(wolfDir: string): void { fs.writeFileSync(hooksPkgPath, JSON.stringify({ type: "module" }, null, 2) + "\n", "utf-8"); } +function removeLegacyTopLevelHooks(hooksDir: string, hookFiles: string[]): void { + for (const file of hookFiles) { + const legacyPath = path.join(hooksDir, file); + if (fs.existsSync(legacyPath)) { + try { + fs.unlinkSync(legacyPath); + } catch {} + } + } +} + /** * Replace all OpenWolf hook entries in settings.json with the current version. * Removes old-style relative-path hooks and inserts the new $CLAUDE_PROJECT_DIR hooks. @@ -474,7 +633,7 @@ function copyHookScripts(wolfDir: string): void { */ function replaceOpenWolfHooks( existing: Record, - hookSettings: typeof HOOK_SETTINGS + hookSettings: typeof CLAUDE_HOOK_SETTINGS ): Record { const merged = { ...existing }; if (!merged.hooks) { diff --git a/src/cli/status.ts b/src/cli/status.ts index 0fb04c35..cbead460 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -40,14 +40,16 @@ export async function statusCommand(): Promise { "post-read.js", "post-write.js", "stop.js", "shared.js", ]; const hooksDir = path.join(wolfDir, "hooks"); - let hooksMissing = 0; - for (const file of hookFiles) { - if (!fs.existsSync(path.join(hooksDir, file))) hooksMissing++; - } - if (hooksMissing === 0) { - console.log(` ✓ All ${hookFiles.length} hook scripts present`); - } else { - console.log(` ✗ Missing ${hooksMissing} hook scripts`); + for (const provider of ["claude", "codex"]) { + let hooksMissing = 0; + for (const file of hookFiles) { + if (!fs.existsSync(path.join(hooksDir, provider, file))) hooksMissing++; + } + if (hooksMissing === 0) { + console.log(` ✓ All ${hookFiles.length} ${provider} hook scripts present`); + } else { + console.log(` ✗ Missing ${hooksMissing} ${provider} hook scripts`); + } } // Claude settings check @@ -63,6 +65,18 @@ export async function statusCommand(): Promise { console.log(" ✗ .claude/settings.json not found"); } + const codexHooksPath = path.join(projectRoot, ".codex", "hooks.json"); + if (fs.existsSync(codexHooksPath)) { + const hooks = readJSON>(codexHooksPath, {}); + const configured = hooks.hooks as Record | undefined; + if (configured) { + const hookCount = Object.values(configured).reduce((sum, arr) => sum + arr.length, 0); + console.log(` ✓ Codex hooks registered (${hookCount} matchers)`); + } + } else { + console.log(" ✗ .codex/hooks.json not found"); + } + // Token ledger stats const ledger = readJSON<{ lifetime: { diff --git a/src/cli/update.ts b/src/cli/update.ts index 33cf5cde..b8b94973 100644 --- a/src/cli/update.ts +++ b/src/cli/update.ts @@ -43,21 +43,40 @@ const BACKUP_FILES = [ ...USER_DATA_FILES, ]; -const HOOK_SETTINGS = { +const CLAUDE_HOOK_SETTINGS = { hooks: { - SessionStart: [{ matcher: "", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/session-start.js"', timeout: 5 }] }], + SessionStart: [{ matcher: "", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/session-start.js"', timeout: 5 }] }], PreToolUse: [ - { matcher: "Read", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-read.js"', timeout: 5 }] }, - { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-write.js"', timeout: 5 }] }, + { matcher: "Read", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/pre-read.js"', timeout: 5 }] }, + { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/pre-write.js"', timeout: 5 }] }, ], PostToolUse: [ - { matcher: "Read", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-read.js"', timeout: 5 }] }, - { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js"', timeout: 10 }] }, + { matcher: "Read", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/post-read.js"', timeout: 5 }] }, + { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/post-write.js"', timeout: 10 }] }, ], - Stop: [{ matcher: "", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/stop.js"', timeout: 10 }] }], + Stop: [{ matcher: "", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/stop.js"', timeout: 10 }] }], }, }; +const CODEX_HOOK_SETTINGS = { + hooks: { + SessionStart: [{ matcher: "startup|resume|clear", hooks: [{ type: "command", command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/session-start.js"', timeout: 5, statusMessage: "OpenWolf session bootstrap" }] }], + PreToolUse: [ + { matcher: "Read", hooks: [{ type: "command", command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/pre-read.js"', timeout: 5, statusMessage: "OpenWolf read precheck" }] }, + { matcher: "Edit|Write|MultiEdit|apply_patch", hooks: [{ type: "command", command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/pre-write.js"', timeout: 5, statusMessage: "OpenWolf write precheck" }] }, + ], + PostToolUse: [ + { matcher: "Read", hooks: [{ type: "command", command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/post-read.js"', timeout: 5, statusMessage: "OpenWolf read tracking" }] }, + { matcher: "Edit|Write|MultiEdit|apply_patch", hooks: [{ type: "command", command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/post-write.js"', timeout: 10, statusMessage: "OpenWolf write tracking" }] }, + ], + Stop: [{ hooks: [{ type: "command", command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/stop.js"', timeout: 10, statusMessage: "OpenWolf session finalize" }] }], + }, +}; + +const CODEX_CONFIG_TOML = `[features] +codex_hooks = true +`; + interface UpdateResult { project: RegisteredProject; status: "updated" | "skipped" | "error"; @@ -185,10 +204,10 @@ async function updateProject( const settingsPath = path.join(claudeDir, "settings.json"); if (fs.existsSync(settingsPath)) { const existing = readJSON>(settingsPath, {}); - const merged = replaceOpenWolfHooks(existing, HOOK_SETTINGS); + const merged = replaceOpenWolfHooks(existing, CLAUDE_HOOK_SETTINGS); writeJSON(settingsPath, merged); } else { - writeJSON(settingsPath, HOOK_SETTINGS); + writeJSON(settingsPath, CLAUDE_HOOK_SETTINGS); } console.log(` ✓ Claude settings updated`); @@ -208,9 +227,32 @@ async function updateProject( writeText(claudeMdPath, snippetContent + "\n\n" + existing); console.log(` ✓ CLAUDE.md updated`); } + } else { + writeText(claudeMdPath, snippetContent); + console.log(` ✓ CLAUDE.md created`); + } + + // 7. Update Codex hooks + AGENTS.md + const codexDir = path.join(root, ".codex"); + ensureDir(codexDir); + writeJSON(path.join(codexDir, "hooks.json"), CODEX_HOOK_SETTINGS); + writeText(path.join(codexDir, "config.toml"), CODEX_CONFIG_TOML); + console.log(` ✓ Codex hooks updated`); + + const agentsPath = path.join(root, "AGENTS.md"); + const agentsSnippet = readTemplateContent("agents-md-snippet.md", templatesDir); + if (fs.existsSync(agentsPath)) { + const existing = readText(agentsPath); + if (!existing.includes("OpenWolf")) { + writeText(agentsPath, agentsSnippet + "\n\n" + existing); + console.log(` ✓ AGENTS.md updated`); + } + } else { + writeText(agentsPath, agentsSnippet); + console.log(` ✓ AGENTS.md created`); } - // 7. Clean up stale .tmp files + // 8. Clean up stale .tmp files try { const files = fs.readdirSync(wolfDir); let cleaned = 0; @@ -222,7 +264,7 @@ async function updateProject( if (cleaned > 0) console.log(` ✓ Cleaned ${cleaned} stale .tmp file(s)`); } catch {} - // 8. Update registry entry + // 9. Update registry entry registerProject(root, name, version); return { @@ -258,15 +300,8 @@ function createBackup(wolfDir: string): string { const hooksDir = path.join(wolfDir, "hooks"); if (fs.existsSync(hooksDir)) { const hooksBackup = path.join(backupDir, "hooks"); - ensureDir(hooksBackup); try { - const hookFiles = fs.readdirSync(hooksDir); - for (const f of hookFiles) { - const src = path.join(hooksDir, f); - if (fs.statSync(src).isFile()) { - fs.copyFileSync(src, path.join(hooksBackup, f)); - } - } + copyDirectoryRecursive(hooksDir, hooksBackup); } catch {} } @@ -285,6 +320,23 @@ function createBackup(wolfDir: string): string { fs.copyFileSync(claudeRules, path.join(rulesBackup, "openwolf.md")); } + const codexHooks = path.join(projectRoot, ".codex", "hooks.json"); + if (fs.existsSync(codexHooks)) { + const codexBackup = path.join(backupDir, ".codex"); + ensureDir(codexBackup); + fs.copyFileSync(codexHooks, path.join(codexBackup, "hooks.json")); + } + const codexConfig = path.join(projectRoot, ".codex", "config.toml"); + if (fs.existsSync(codexConfig)) { + const codexBackup = path.join(backupDir, ".codex"); + ensureDir(codexBackup); + fs.copyFileSync(codexConfig, path.join(codexBackup, "config.toml")); + } + const agentsPath = path.join(projectRoot, "AGENTS.md"); + if (fs.existsSync(agentsPath)) { + fs.copyFileSync(agentsPath, path.join(backupDir, "AGENTS.md")); + } + return backupDir; } @@ -310,6 +362,7 @@ function readTemplateContent(filename: string, templatesDir: string): string { } const templates: Record = { "claude-md-snippet.md": `# OpenWolf\n\n@.wolf/OPENWOLF.md\n\nThis project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md every session. Check .wolf/cerebrum.md before generating code. Check .wolf/anatomy.md before reading files.`, + "agents-md-snippet.md": `# OpenWolf\n\n@.wolf/OPENWOLF.md\n\nThis project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md every session. Check .wolf/cerebrum.md before generating code. Check .wolf/anatomy.md before reading files.`, "claude-rules-openwolf.md": `---\ndescription: OpenWolf protocol enforcement — active on all files\nglobs: **/*\n---\n\n- Check .wolf/anatomy.md before reading any project file\n- Check .wolf/cerebrum.md Do-Not-Repeat list before generating code\n- After writing or editing files, update .wolf/anatomy.md and append to .wolf/memory.md\n- After receiving a user correction, update .wolf/cerebrum.md immediately (Preferences, Learnings, or Do-Not-Repeat)\n- LEARN from every interaction: if you discover a convention, user preference, or project pattern, add it to .wolf/cerebrum.md. Low threshold — when in doubt, log it.\n- BEFORE fixing any bug or error: read .wolf/buglog.json for known fixes\n- AFTER fixing any bug, error, failed test, failed build, or user-reported problem: ALWAYS log to .wolf/buglog.json with error_message, root_cause, fix, and tags\n- If you edit a file more than twice in a session, that likely indicates a bug — log it to .wolf/buglog.json\n- When the user asks to check/evaluate UI design: run \`openwolf designqc\` to capture screenshots, then read them from .wolf/designqc-captures/\n- When the user asks to change/pick/migrate UI framework: read .wolf/reframe-frameworks.md, ask decision questions, recommend a framework, then execute with the framework's prompt`, }; return templates[filename] ?? ""; @@ -339,10 +392,15 @@ function copyHookScripts(wolfDir: string): void { ]; if (sourceDir) { - for (const file of hookFiles) { - const src = path.join(sourceDir, file); - if (fs.existsSync(src)) { - fs.copyFileSync(src, path.join(hooksDir, file)); + removeLegacyTopLevelHooks(hooksDir, hookFiles); + for (const provider of ["claude", "codex"]) { + const providerDir = path.join(hooksDir, provider); + ensureDir(providerDir); + for (const file of hookFiles) { + const src = path.join(sourceDir, file); + if (fs.existsSync(src)) { + fs.copyFileSync(src, path.join(providerDir, file)); + } } } } @@ -352,9 +410,20 @@ function copyHookScripts(wolfDir: string): void { fs.writeFileSync(hooksPkgPath, JSON.stringify({ type: "module" }, null, 2) + "\n", "utf-8"); } +function removeLegacyTopLevelHooks(hooksDir: string, hookFiles: string[]): void { + for (const file of hookFiles) { + const legacyPath = path.join(hooksDir, file); + if (fs.existsSync(legacyPath)) { + try { + fs.unlinkSync(legacyPath); + } catch {} + } + } +} + function replaceOpenWolfHooks( existing: Record, - hookSettings: typeof HOOK_SETTINGS + hookSettings: typeof CLAUDE_HOOK_SETTINGS ): Record { const merged = { ...existing }; if (!merged.hooks) merged.hooks = {}; @@ -449,12 +518,8 @@ export function restoreCommand(backupName?: string): void { // Restore hooks if present const hooksBackup = path.join(backupDir, "hooks"); if (fs.existsSync(hooksBackup)) { - const hookFiles = fs.readdirSync(hooksBackup); const hooksDir = path.join(wolfDir, "hooks"); - ensureDir(hooksDir); - for (const f of hookFiles) { - fs.copyFileSync(path.join(hooksBackup, f), path.join(hooksDir, f)); - } + copyDirectoryRecursive(hooksBackup, hooksDir); } // Restore .claude settings if present @@ -475,5 +540,40 @@ export function restoreCommand(backupName?: string): void { } } + const codexBackup = path.join(backupDir, ".codex"); + if (fs.existsSync(codexBackup)) { + const projectRoot = path.dirname(wolfDir); + const hooksBackup = path.join(codexBackup, "hooks.json"); + if (fs.existsSync(hooksBackup)) { + const dest = path.join(projectRoot, ".codex", "hooks.json"); + ensureDir(path.dirname(dest)); + fs.copyFileSync(hooksBackup, dest); + } + const configBackup = path.join(codexBackup, "config.toml"); + if (fs.existsSync(configBackup)) { + const dest = path.join(projectRoot, ".codex", "config.toml"); + ensureDir(path.dirname(dest)); + fs.copyFileSync(configBackup, dest); + } + } + + const agentsBackup = path.join(backupDir, "AGENTS.md"); + if (fs.existsSync(agentsBackup)) { + fs.copyFileSync(agentsBackup, path.join(path.dirname(wolfDir), "AGENTS.md")); + } + console.log(`Restored ${files.length} files from backup "${backupName}".`); } + +function copyDirectoryRecursive(srcDir: string, destDir: string): void { + ensureDir(destDir); + for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { + const src = path.join(srcDir, entry.name); + const dest = path.join(destDir, entry.name); + if (entry.isDirectory()) { + copyDirectoryRecursive(src, dest); + } else if (entry.isFile()) { + fs.copyFileSync(src, dest); + } + } +} diff --git a/src/hooks/post-read.ts b/src/hooks/post-read.ts index 17c82804..39c1f98b 100644 --- a/src/hooks/post-read.ts +++ b/src/hooks/post-read.ts @@ -1,5 +1,5 @@ import * as path from "node:path"; -import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, parseAnatomy, estimateTokens, readStdin, normalizePath } from "./shared.js"; +import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, parseAnatomy, estimateTokens, readStdin, normalizePath, getProjectDir } from "./shared.js"; interface SessionData { files_read: Record; @@ -28,7 +28,7 @@ async function main(): Promise { const normalizedFile = normalizePath(filePath); // Skip tracking for .wolf/ internal files — consistent with pre-read - const projectDir = normalizePath(process.env.CLAUDE_PROJECT_DIR || process.cwd()); + const projectDir = normalizePath(getProjectDir()); const relToProject = normalizedFile.startsWith(projectDir) ? normalizedFile.slice(projectDir.length).replace(/^\//, "") : ""; diff --git a/src/hooks/post-write.ts b/src/hooks/post-write.ts index 3190cb20..d8a1925e 100644 --- a/src/hooks/post-write.ts +++ b/src/hooks/post-write.ts @@ -3,7 +3,7 @@ import * as path from "node:path"; import * as crypto from "node:crypto"; import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, parseAnatomy, serializeAnatomy, - extractDescription, estimateTokens, appendMarkdown, timeShort, readStdin, normalizePath + extractDescription, estimateTokens, appendMarkdown, timeShort, readStdin, normalizePath, getProjectDir } from "./shared.js"; interface SessionData { @@ -35,7 +35,7 @@ async function main(): Promise { const wolfDir = getWolfDir(); const hooksDir = path.join(wolfDir, "hooks"); const sessionFile = path.join(hooksDir, "_session.json"); - const projectRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd(); + const projectRoot = getProjectDir(); const raw = await readStdin(); let input: { tool_name?: string; tool_input?: { file_path?: string; path?: string; content?: string; old_string?: string; new_string?: string } }; diff --git a/src/hooks/pre-read.ts b/src/hooks/pre-read.ts index 61e933f0..0d5e947a 100644 --- a/src/hooks/pre-read.ts +++ b/src/hooks/pre-read.ts @@ -1,7 +1,7 @@ import * as path from "node:path"; import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, parseAnatomy, - estimateTokens, readStdin, normalizePath + estimateTokens, readStdin, normalizePath, getProjectDir } from "./shared.js"; interface SessionData { @@ -35,7 +35,7 @@ async function main(): Promise { // Skip tracking for .wolf/ internal files — they're infrastructure, not project files. // Counting them inflates anatomy miss rates since .wolf/ is excluded from anatomy scanning. - const projectDir = normalizePath(process.env.CLAUDE_PROJECT_DIR || process.cwd()); + const projectDir = normalizePath(getProjectDir()); const relToProject = normalizedFile.startsWith(projectDir) ? normalizedFile.slice(projectDir.length).replace(/^\//, "") : ""; diff --git a/src/hooks/shared.ts b/src/hooks/shared.ts index 890a20e8..a7f44530 100644 --- a/src/hooks/shared.ts +++ b/src/hooks/shared.ts @@ -2,10 +2,12 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as crypto from "node:crypto"; +export function getProjectDir(): string { + return process.cwd(); +} + export function getWolfDir(): string { - // Prefer CLAUDE_PROJECT_DIR so hooks work even if CWD changes during a session - const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd(); - return path.join(projectDir, ".wolf"); + return path.join(getProjectDir(), ".wolf"); } /** diff --git a/src/templates/agents-md-snippet.md b/src/templates/agents-md-snippet.md new file mode 100644 index 00000000..11942b2f --- /dev/null +++ b/src/templates/agents-md-snippet.md @@ -0,0 +1,5 @@ +# OpenWolf + +@.wolf/OPENWOLF.md + +This project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md every session. Check .wolf/cerebrum.md before generating code. Check .wolf/anatomy.md before reading files. From 54a0ae4b332a151a8e087fbf5d6f2629db931bc7 Mon Sep 17 00:00:00 2001 From: nottyjay Date: Tue, 9 Jun 2026 16:39:41 +0800 Subject: [PATCH 2/9] Fix Codex hook paths for non-git projects --- package.json | 2 +- src/cli/codex-config.ts | 5 +++++ src/cli/init.test.ts | 17 +++++++++++++++++ src/cli/init.ts | 21 ++++++++++----------- src/cli/update.ts | 21 ++++++++++----------- src/hooks/session-start.ts | 31 +++++++++++++++++++++---------- src/hooks/shared.ts | 6 ++++++ 7 files changed, 70 insertions(+), 33 deletions(-) create mode 100644 src/cli/codex-config.ts diff --git a/package.json b/package.json index 92ae06e9..767ede57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openwolf", - "version": "1.0.4", + "version": "1.0.5", "description": "Token-conscious AI brain for Claude Code projects", "type": "module", "bin": { diff --git a/src/cli/codex-config.ts b/src/cli/codex-config.ts new file mode 100644 index 00000000..cdaa11fb --- /dev/null +++ b/src/cli/codex-config.ts @@ -0,0 +1,5 @@ +export function getCodexConfigToml(): string { + return `[features] +hooks = true +`; +} diff --git a/src/cli/init.test.ts b/src/cli/init.test.ts index 1988d8bf..f130b06c 100644 --- a/src/cli/init.test.ts +++ b/src/cli/init.test.ts @@ -34,10 +34,12 @@ test("default init installs both Claude and Codex integration files", async () = const projectRoot = makeProject("dual-target"); const previousCwd = process.cwd(); const previousHome = process.env.HOME; + const previousCodexVersion = process.env.OPENWOLF_CODEX_VERSION; const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "openwolf-home-")); process.chdir(projectRoot); process.env.HOME = fakeHome; + process.env.OPENWOLF_CODEX_VERSION = "0.129.0"; try { await runInit(); @@ -59,9 +61,16 @@ test("default init installs both Claude and Codex integration files", async () = JSON.stringify(readJSON(path.join(projectRoot, ".codex", "hooks.json"))), /\.wolf\/hooks\/codex\/session-start\.js/ ); + assert.match( + JSON.stringify(readJSON(path.join(projectRoot, ".codex", "hooks.json"))), + /git rev-parse --show-toplevel 2>\/dev\/null \|\| pwd/ + ); + assert.match(read(path.join(projectRoot, ".codex", "config.toml")), /hooks = true/); + assert.doesNotMatch(read(path.join(projectRoot, ".codex", "config.toml")), /codex_hooks = true/); } finally { process.chdir(previousCwd); process.env.HOME = previousHome; + process.env.OPENWOLF_CODEX_VERSION = previousCodexVersion; fs.rmSync(projectRoot, { recursive: true, force: true }); fs.rmSync(fakeHome, { recursive: true, force: true }); } @@ -71,10 +80,12 @@ test("claude target only installs Claude integration files", async () => { const projectRoot = makeProject("claude-only"); const previousCwd = process.cwd(); const previousHome = process.env.HOME; + const previousCodexVersion = process.env.OPENWOLF_CODEX_VERSION; const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "openwolf-home-")); process.chdir(projectRoot); process.env.HOME = fakeHome; + process.env.OPENWOLF_CODEX_VERSION = "0.129.0"; try { await runInit("claude"); @@ -89,6 +100,7 @@ test("claude target only installs Claude integration files", async () => { } finally { process.chdir(previousCwd); process.env.HOME = previousHome; + process.env.OPENWOLF_CODEX_VERSION = previousCodexVersion; fs.rmSync(projectRoot, { recursive: true, force: true }); fs.rmSync(fakeHome, { recursive: true, force: true }); } @@ -98,10 +110,12 @@ test("codex target only installs Codex integration files", async () => { const projectRoot = makeProject("codex-only"); const previousCwd = process.cwd(); const previousHome = process.env.HOME; + const previousCodexVersion = process.env.OPENWOLF_CODEX_VERSION; const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "openwolf-home-")); process.chdir(projectRoot); process.env.HOME = fakeHome; + process.env.OPENWOLF_CODEX_VERSION = "0.129.0"; try { await runInit("codex"); @@ -113,9 +127,12 @@ test("codex target only installs Codex integration files", async () => { assert.equal(fs.existsSync(path.join(projectRoot, ".wolf", "hooks", "claude", "session-start.js")), false); assert.equal(fs.existsSync(path.join(projectRoot, "CLAUDE.md")), false); assert.equal(fs.existsSync(path.join(projectRoot, ".claude", "settings.json")), false); + assert.match(read(path.join(projectRoot, ".codex", "config.toml")), /hooks = true/); + assert.doesNotMatch(read(path.join(projectRoot, ".codex", "config.toml")), /codex_hooks = true/); } finally { process.chdir(previousCwd); process.env.HOME = previousHome; + process.env.OPENWOLF_CODEX_VERSION = previousCodexVersion; fs.rmSync(projectRoot, { recursive: true, force: true }); fs.rmSync(fakeHome, { recursive: true, force: true }); } diff --git a/src/cli/init.ts b/src/cli/init.ts index 3c464fba..cc96a6ae 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -8,6 +8,7 @@ import { readJSON, writeJSON, readText, writeText } from "../utils/fs-safe.js"; import { ensureDir } from "../utils/paths.js"; import { isWindows } from "../utils/platform.js"; import { registerProject } from "./registry.js"; +import { getCodexConfigToml } from "./codex-config.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -120,6 +121,8 @@ const CLAUDE_HOOK_SETTINGS = { }, }; +const CODEX_PROJECT_ROOT = '$(git rev-parse --show-toplevel 2>/dev/null || pwd)'; + const CODEX_HOOK_SETTINGS = { hooks: { SessionStart: [ @@ -128,7 +131,7 @@ const CODEX_HOOK_SETTINGS = { hooks: [ { type: "command", - command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/session-start.js"', + command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/session-start.js"`, timeout: 5, statusMessage: "OpenWolf session bootstrap", }, @@ -141,7 +144,7 @@ const CODEX_HOOK_SETTINGS = { hooks: [ { type: "command", - command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/pre-read.js"', + command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/pre-read.js"`, timeout: 5, statusMessage: "OpenWolf read precheck", }, @@ -152,7 +155,7 @@ const CODEX_HOOK_SETTINGS = { hooks: [ { type: "command", - command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/pre-write.js"', + command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/pre-write.js"`, timeout: 5, statusMessage: "OpenWolf write precheck", }, @@ -165,7 +168,7 @@ const CODEX_HOOK_SETTINGS = { hooks: [ { type: "command", - command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/post-read.js"', + command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/post-read.js"`, timeout: 5, statusMessage: "OpenWolf read tracking", }, @@ -176,7 +179,7 @@ const CODEX_HOOK_SETTINGS = { hooks: [ { type: "command", - command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/post-write.js"', + command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/post-write.js"`, timeout: 10, statusMessage: "OpenWolf write tracking", }, @@ -188,7 +191,7 @@ const CODEX_HOOK_SETTINGS = { hooks: [ { type: "command", - command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/stop.js"', + command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/stop.js"`, timeout: 10, statusMessage: "OpenWolf session finalize", }, @@ -198,10 +201,6 @@ const CODEX_HOOK_SETTINGS = { }, }; -const CODEX_CONFIG_TOML = `[features] -codex_hooks = true -`; - export async function initCommand(targetArg?: string): Promise { // Check Node.js version const nodeVersion = parseInt(process.version.slice(1), 10); @@ -416,7 +415,7 @@ function installCodexIntegration(projectRoot: string, templatesDir: string): voi ensureDir(codexDir); writeJSON(path.join(codexDir, "hooks.json"), CODEX_HOOK_SETTINGS); - writeText(path.join(codexDir, "config.toml"), CODEX_CONFIG_TOML); + writeText(path.join(codexDir, "config.toml"), getCodexConfigToml()); const agentsPath = path.join(projectRoot, "AGENTS.md"); const snippetContent = readTemplateContent("agents-md-snippet.md", templatesDir); diff --git a/src/cli/update.ts b/src/cli/update.ts index b8b94973..62dc7b4d 100644 --- a/src/cli/update.ts +++ b/src/cli/update.ts @@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url"; import { getRegisteredProjects, registerProject, type RegisteredProject } from "./registry.js"; import { readJSON, writeJSON, readText, writeText } from "../utils/fs-safe.js"; import { ensureDir } from "../utils/paths.js"; +import { getCodexConfigToml } from "./codex-config.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -58,25 +59,23 @@ const CLAUDE_HOOK_SETTINGS = { }, }; +const CODEX_PROJECT_ROOT = '$(git rev-parse --show-toplevel 2>/dev/null || pwd)'; + const CODEX_HOOK_SETTINGS = { hooks: { - SessionStart: [{ matcher: "startup|resume|clear", hooks: [{ type: "command", command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/session-start.js"', timeout: 5, statusMessage: "OpenWolf session bootstrap" }] }], + SessionStart: [{ matcher: "startup|resume|clear", hooks: [{ type: "command", command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/session-start.js"`, timeout: 5, statusMessage: "OpenWolf session bootstrap" }] }], PreToolUse: [ - { matcher: "Read", hooks: [{ type: "command", command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/pre-read.js"', timeout: 5, statusMessage: "OpenWolf read precheck" }] }, - { matcher: "Edit|Write|MultiEdit|apply_patch", hooks: [{ type: "command", command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/pre-write.js"', timeout: 5, statusMessage: "OpenWolf write precheck" }] }, + { matcher: "Read", hooks: [{ type: "command", command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/pre-read.js"`, timeout: 5, statusMessage: "OpenWolf read precheck" }] }, + { matcher: "Edit|Write|MultiEdit|apply_patch", hooks: [{ type: "command", command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/pre-write.js"`, timeout: 5, statusMessage: "OpenWolf write precheck" }] }, ], PostToolUse: [ - { matcher: "Read", hooks: [{ type: "command", command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/post-read.js"', timeout: 5, statusMessage: "OpenWolf read tracking" }] }, - { matcher: "Edit|Write|MultiEdit|apply_patch", hooks: [{ type: "command", command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/post-write.js"', timeout: 10, statusMessage: "OpenWolf write tracking" }] }, + { matcher: "Read", hooks: [{ type: "command", command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/post-read.js"`, timeout: 5, statusMessage: "OpenWolf read tracking" }] }, + { matcher: "Edit|Write|MultiEdit|apply_patch", hooks: [{ type: "command", command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/post-write.js"`, timeout: 10, statusMessage: "OpenWolf write tracking" }] }, ], - Stop: [{ hooks: [{ type: "command", command: 'node "$(git rev-parse --show-toplevel)/.wolf/hooks/codex/stop.js"', timeout: 10, statusMessage: "OpenWolf session finalize" }] }], + Stop: [{ hooks: [{ type: "command", command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/stop.js"`, timeout: 10, statusMessage: "OpenWolf session finalize" }] }], }, }; -const CODEX_CONFIG_TOML = `[features] -codex_hooks = true -`; - interface UpdateResult { project: RegisteredProject; status: "updated" | "skipped" | "error"; @@ -236,7 +235,7 @@ async function updateProject( const codexDir = path.join(root, ".codex"); ensureDir(codexDir); writeJSON(path.join(codexDir, "hooks.json"), CODEX_HOOK_SETTINGS); - writeText(path.join(codexDir, "config.toml"), CODEX_CONFIG_TOML); + writeText(path.join(codexDir, "config.toml"), getCodexConfigToml()); console.log(` ✓ Codex hooks updated`); const agentsPath = path.join(root, "AGENTS.md"); diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index 78206240..b13404dd 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -1,10 +1,12 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { getWolfDir, ensureWolfDir, writeJSON, appendMarkdown, readJSON, timestamp, timeShort } from "./shared.js"; +import { getWolfDir, ensureWolfDir, writeJSON, appendMarkdown, readJSON, timestamp, timeShort, getHookProvider } from "./shared.js"; async function main(): Promise { ensureWolfDir(); const wolfDir = getWolfDir(); + const provider = getHookProvider(); + const notices: string[] = []; // Clean up stale .tmp files left from failed atomic writes try { @@ -53,13 +55,9 @@ async function main(): Promise { }); if (entryLines.length < 3) { - process.stderr.write( - `💡 OpenWolf: cerebrum.md has only ${entryLines.length} entries. Learn from this session — record user preferences, project conventions, and mistakes to .wolf/cerebrum.md.\n` - ); + notices.push(`OpenWolf: cerebrum.md has only ${entryLines.length} entries. Learn from this session and record preferences, conventions, and mistakes to .wolf/cerebrum.md.`); } else if (daysSinceUpdate > 3) { - process.stderr.write( - `💡 OpenWolf: cerebrum.md hasn't been updated in ${Math.floor(daysSinceUpdate)} days. Look for opportunities to add learnings this session.\n` - ); + notices.push(`OpenWolf: cerebrum.md hasn't been updated in ${Math.floor(daysSinceUpdate)} days. Look for opportunities to add learnings this session.`); } } catch {} @@ -68,9 +66,7 @@ async function main(): Promise { const buglogPath = path.join(wolfDir, "buglog.json"); const buglog = readJSON<{ bugs: unknown[] }>(buglogPath, { bugs: [] }); if (buglog.bugs.length === 0) { - process.stderr.write( - `📋 OpenWolf: buglog.json is empty. If you encounter or fix any bugs, errors, or failed tests this session, log them to .wolf/buglog.json.\n` - ); + notices.push("OpenWolf: buglog.json is empty. If you encounter or fix bugs, errors, or failed tests this session, log them to .wolf/buglog.json."); } } catch {} @@ -84,6 +80,21 @@ async function main(): Promise { ledger.lifetime.total_sessions++; writeJSON(ledgerPath, ledger); + if (notices.length > 0) { + if (provider === "codex") { + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: notices.join("\n"), + }, + })); + } else { + for (const notice of notices) { + process.stderr.write(`${notice}\n`); + } + } + } + process.exit(0); } diff --git a/src/hooks/shared.ts b/src/hooks/shared.ts index a7f44530..6c25d38e 100644 --- a/src/hooks/shared.ts +++ b/src/hooks/shared.ts @@ -6,6 +6,12 @@ export function getProjectDir(): string { return process.cwd(); } +export function getHookProvider(): "claude" | "codex" { + const scriptPath = normalizePath(process.argv[1] || ""); + if (scriptPath.includes("/codex/")) return "codex"; + return "claude"; +} + export function getWolfDir(): string { return path.join(getProjectDir(), ".wolf"); } From a230430cc8788d3b2931ebe0b7cfc1626ef4b39f Mon Sep 17 00:00:00 2001 From: nottyjay Date: Tue, 9 Jun 2026 17:37:55 +0800 Subject: [PATCH 3/9] change name --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 767ede57..abc18559 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "openwolf", + "name": "@alptech/openwolf", "version": "1.0.5", "description": "Token-conscious AI brain for Claude Code projects", "type": "module", From 9e24b688ad27f7b194c160829d8b4d26a253fc84 Mon Sep 17 00:00:00 2001 From: nottyjay Date: Wed, 24 Jun 2026 15:14:26 +0800 Subject: [PATCH 4/9] fix: Claude Code hook compatibility with new hook system - Switch hook config from shell form to exec form with args array - Pass project directory as CLI arg to hooks (no longer rely on process.cwd()) - Fix placeholder format: $CLAUDE_PROJECT_DIR -> ${CLAUDE_PROJECT_DIR} - SessionStart: use additionalContext JSON stdout for both Claude and Codex - replaceOpenWolfHooks: detect hooks by args in addition to command - Update README for fork relationship and new npm package name - Update package.json repository URLs and author info --- README.md | 12 +++++++----- package.json | 10 ++++++---- src/cli/init.ts | 28 ++++++++++++++++++---------- src/cli/update.ts | 19 ++++++++++--------- src/hooks/session-start.ts | 21 +++++++-------------- src/hooks/shared.ts | 5 +++++ 6 files changed, 53 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 37e8faf1..2cf5ee17 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ OpenWolf demo

-

OpenWolf

+

@alptech/openwolf

A second brain for Claude Code and Codex.
@@ -10,11 +10,13 @@

- npm version + npm version License: AGPL-3.0 Node.js

+> **This is a fork of [openwolf](https://www.npmjs.com/package/openwolf) by [Cytostack](https://github.com/cytostack).** It preserves all original capabilities and adds native, first-class [Codex](https://github.com/openai/codex) integration alongside the existing Claude Code support. Both agents get equal treatment — dedicated hook scripts, project entry files, and config scaffolding. + --- ## Why OpenWolf Exists @@ -40,7 +42,7 @@ Across 20 projects, 132+ sessions: average token reduction of 65.8%, with 71% of ## Quick Start ```bash -npm install -g openwolf +npm install -g @alptech/openwolf cd your-project openwolf init ``` @@ -240,7 +242,7 @@ OpenWolf is not an AI wrapper. It is 6 hook scripts and a `.wolf/` directory. It - Codex hooks require Codex project hook support via `.codex/hooks.json` and `.codex/config.toml`, plus `AGENTS.md` project instructions. - Token tracking is estimation-based (character-to-token ratio), not exact API counts. Accurate to within ~15%. - `cerebrum.md` depends on Claude following instructions to update it after corrections. Compliance is ~85-90%, not 100%. -- This is v1.0.4. Things may break. [File issues](https://github.com/cytostack/openwolf/issues). +- This is v1.0.5. Things may break. [File issues](https://github.com/nottyjay/openwolf/issues). ## Origin Story @@ -252,4 +254,4 @@ We were building products with Claude Code at Cytostack when we noticed somethin ## Author -Built by Farhan Palathinkal Afsal - [Cytostack](https://github.com/cytostack) +Original project by Farhan Palathinkal Afsal — [Cytostack](https://github.com/cytostack). Forked and extended with first-class Codex integration by [@alptech](https://www.npmjs.com/package/@alptech/openwolf). diff --git a/package.json b/package.json index abc18559..0e965e86 100644 --- a/package.json +++ b/package.json @@ -49,22 +49,24 @@ "node": ">=20.0.0" }, "license": "AGPL-3.0-only", - "author": "Cytostack Pvt Ltd", + "author": "Cytostack Pvt Ltd (original) / alptech (fork)", "repository": { "type": "git", - "url": "https://github.com/cytostack/openwolf.git" + "url": "https://github.com/nottyjay/openwolf.git" }, - "homepage": "https://github.com/cytostack/openwolf", + "homepage": "https://github.com/nottyjay/openwolf", "bugs": { - "url": "https://github.com/cytostack/openwolf/issues" + "url": "https://github.com/nottyjay/openwolf/issues" }, "keywords": [ "claude", + "codex", "ai", "context", "token", "openwolf", "claude-code", + "codex-cli", "developer-tools" ], "files": [ diff --git a/src/cli/init.ts b/src/cli/init.ts index cc96a6ae..547e7fc1 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -47,7 +47,8 @@ const CREATE_IF_MISSING = [ type InitTarget = "claude" | "codex"; -// Use $CLAUDE_PROJECT_DIR so hooks resolve correctly even if CWD changes during a session +// Use ${CLAUDE_PROJECT_DIR} with exec form so hooks resolve correctly regardless of CWD. +// The second arg passes the project dir to the hook scripts, making them independent of process.cwd(). const CLAUDE_HOOK_SETTINGS = { hooks: { SessionStart: [ @@ -56,7 +57,8 @@ const CLAUDE_HOOK_SETTINGS = { hooks: [ { type: "command", - command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/session-start.js"', + command: "node", + args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/session-start.js", "${CLAUDE_PROJECT_DIR}"], timeout: 5, }, ], @@ -68,7 +70,8 @@ const CLAUDE_HOOK_SETTINGS = { hooks: [ { type: "command", - command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/pre-read.js"', + command: "node", + args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/pre-read.js", "${CLAUDE_PROJECT_DIR}"], timeout: 5, }, ], @@ -78,7 +81,8 @@ const CLAUDE_HOOK_SETTINGS = { hooks: [ { type: "command", - command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/pre-write.js"', + command: "node", + args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/pre-write.js", "${CLAUDE_PROJECT_DIR}"], timeout: 5, }, ], @@ -90,7 +94,8 @@ const CLAUDE_HOOK_SETTINGS = { hooks: [ { type: "command", - command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/post-read.js"', + command: "node", + args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/post-read.js", "${CLAUDE_PROJECT_DIR}"], timeout: 5, }, ], @@ -100,7 +105,8 @@ const CLAUDE_HOOK_SETTINGS = { hooks: [ { type: "command", - command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/post-write.js"', + command: "node", + args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/post-write.js", "${CLAUDE_PROJECT_DIR}"], timeout: 10, }, ], @@ -112,7 +118,8 @@ const CLAUDE_HOOK_SETTINGS = { hooks: [ { type: "command", - command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/stop.js"', + command: "node", + args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/stop.js", "${CLAUDE_PROJECT_DIR}"], timeout: 10, }, ], @@ -638,17 +645,18 @@ function replaceOpenWolfHooks( if (!merged.hooks) { merged.hooks = {}; } - const hooks = merged.hooks as Record }>>; + const hooks = merged.hooks as Record }>>; for (const [event, newMatchers] of Object.entries(hookSettings.hooks)) { if (!hooks[event]) { hooks[event] = []; } - // Remove any existing OpenWolf hook entries (match by .wolf/hooks/ in command) + // Remove any existing OpenWolf hook entries (match by .wolf/hooks/ in command or args) hooks[event] = hooks[event].filter((entry) => { const isOpenWolfHook = entry.hooks?.some( - (h) => h.command && h.command.includes(".wolf/hooks/") + (h) => (h.command && h.command.includes(".wolf/hooks/")) || + (h.args && h.args.some((a: string) => a.includes(".wolf/hooks/"))) ); return !isOpenWolfHook; }); diff --git a/src/cli/update.ts b/src/cli/update.ts index 62dc7b4d..90503b4b 100644 --- a/src/cli/update.ts +++ b/src/cli/update.ts @@ -46,16 +46,16 @@ const BACKUP_FILES = [ const CLAUDE_HOOK_SETTINGS = { hooks: { - SessionStart: [{ matcher: "", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/session-start.js"', timeout: 5 }] }], + SessionStart: [{ matcher: "", hooks: [{ type: "command", command: "node", args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/session-start.js", "${CLAUDE_PROJECT_DIR}"], timeout: 5 }] }], PreToolUse: [ - { matcher: "Read", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/pre-read.js"', timeout: 5 }] }, - { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/pre-write.js"', timeout: 5 }] }, + { matcher: "Read", hooks: [{ type: "command", command: "node", args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/pre-read.js", "${CLAUDE_PROJECT_DIR}"], timeout: 5 }] }, + { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: "node", args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/pre-write.js", "${CLAUDE_PROJECT_DIR}"], timeout: 5 }] }, ], PostToolUse: [ - { matcher: "Read", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/post-read.js"', timeout: 5 }] }, - { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/post-write.js"', timeout: 10 }] }, + { matcher: "Read", hooks: [{ type: "command", command: "node", args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/post-read.js", "${CLAUDE_PROJECT_DIR}"], timeout: 5 }] }, + { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: "node", args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/post-write.js", "${CLAUDE_PROJECT_DIR}"], timeout: 10 }] }, ], - Stop: [{ matcher: "", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/claude/stop.js"', timeout: 10 }] }], + Stop: [{ matcher: "", hooks: [{ type: "command", command: "node", args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/stop.js", "${CLAUDE_PROJECT_DIR}"], timeout: 10 }] }], }, }; @@ -426,15 +426,16 @@ function replaceOpenWolfHooks( ): Record { const merged = { ...existing }; if (!merged.hooks) merged.hooks = {}; - const hooks = merged.hooks as Record }>>; + const hooks = merged.hooks as Record }>>; for (const [event, newMatchers] of Object.entries(hookSettings.hooks)) { if (!hooks[event]) hooks[event] = []; - // Remove existing OpenWolf hook entries + // Remove existing OpenWolf hook entries (match by .wolf/hooks/ in command or args) hooks[event] = hooks[event].filter((entry) => { const isOpenWolfHook = entry.hooks?.some( - (h) => h.command && h.command.includes(".wolf/hooks/") + (h) => (h.command && h.command.includes(".wolf/hooks/")) || + (h.args && h.args.some((a: string) => a.includes(".wolf/hooks/"))) ); return !isOpenWolfHook; }); diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index b13404dd..e197835f 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -1,11 +1,10 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { getWolfDir, ensureWolfDir, writeJSON, appendMarkdown, readJSON, timestamp, timeShort, getHookProvider } from "./shared.js"; +import { getWolfDir, ensureWolfDir, writeJSON, appendMarkdown, readJSON, timestamp, timeShort } from "./shared.js"; async function main(): Promise { ensureWolfDir(); const wolfDir = getWolfDir(); - const provider = getHookProvider(); const notices: string[] = []; // Clean up stale .tmp files left from failed atomic writes @@ -81,18 +80,12 @@ async function main(): Promise { writeJSON(ledgerPath, ledger); if (notices.length > 0) { - if (provider === "codex") { - process.stdout.write(JSON.stringify({ - hookSpecificOutput: { - hookEventName: "SessionStart", - additionalContext: notices.join("\n"), - }, - })); - } else { - for (const notice of notices) { - process.stderr.write(`${notice}\n`); - } - } + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { + hookEventName: "SessionStart", + additionalContext: notices.join("\n"), + }, + })); } process.exit(0); diff --git a/src/hooks/shared.ts b/src/hooks/shared.ts index 6c25d38e..bd267310 100644 --- a/src/hooks/shared.ts +++ b/src/hooks/shared.ts @@ -3,6 +3,11 @@ import * as path from "node:path"; import * as crypto from "node:crypto"; export function getProjectDir(): string { + // argv[2] is the first user argument after the script path, passed by hook config. + // Newer Claude Code may not run hooks from the project directory — rely on the + // explicit project-dir argument when available, falling back to process.cwd(). + const argDir = process.argv[2]; + if (argDir && fs.existsSync(argDir)) return argDir; return process.cwd(); } From f3023024426e6742737191d604bb10e66c681604 Mon Sep 17 00:00:00 2001 From: nottyjay Date: Wed, 24 Jun 2026 15:16:03 +0800 Subject: [PATCH 5/9] chore: bump version to 1.0.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0e965e86..73d7479b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@alptech/openwolf", - "version": "1.0.5", + "version": "1.0.6", "description": "Token-conscious AI brain for Claude Code projects", "type": "module", "bin": { From 4b553cae8a0a71e5cfeb8ec99728c1ba0606f1a1 Mon Sep 17 00:00:00 2001 From: Nottyjay Date: Wed, 12 Aug 2026 14:40:17 +0800 Subject: [PATCH 6/9] sync: upstream business features from cytostack/openwolf Track upstream multi-agent adapters, PreCompact, durable anatomy store, measured token usage, dashboard redesign, and bundled skills. Keep @alptech/openwolf package name, author, and version 1.0.6. Update README, add Chinese README with upstream acknowledgments, retire Design QC. --- CHANGELOG.md | 190 ++++++ README.md | 380 ++++++------ README.zh-CN.md | 227 ++++++++ RELEASE_NOTES_2.0.0.md | 63 ++ assets/openwolf-dashboard.png | Bin 0 -> 102743 bytes package.json | 17 +- pnpm-lock.yaml | 549 ++++++++---------- pnpm-workspace.yaml | 2 + scripts/openwolf-check.mjs | 126 ++++ src/agents/antigravity.ts | 20 + src/agents/codex.ts | 79 +++ src/agents/cursor.ts | 26 + src/agents/gemini.ts | 21 + src/agents/index.ts | 98 ++++ src/agents/markers.ts | 33 ++ src/agents/opencode.ts | 50 ++ src/agents/skills.ts | 43 ++ src/agents/types.ts | 27 + src/buglog/bug-tracker.ts | 20 +- src/cli/codex-config.ts | 5 - src/cli/cron-cmd.ts | 4 +- src/cli/daemon-cmd.ts | 30 +- src/cli/dashboard.ts | 89 ++- src/cli/designqc-cmd.ts | 56 -- src/cli/index.ts | 32 +- src/cli/init.test.ts | 139 ----- src/cli/init.ts | 486 ++++++++-------- src/cli/report.ts | 70 +++ src/cli/status.ts | 30 +- src/cli/update.ts | 314 +++++----- src/daemon/cron-engine.ts | 42 +- src/daemon/file-watcher.ts | 7 + src/daemon/wolf-daemon.ts | 79 ++- src/dashboard/app/App.tsx | 62 +- .../app/components/layout/Header.tsx | 28 - .../app/components/layout/Layout.tsx | 2 +- .../app/components/layout/Sidebar.tsx | 105 ---- .../app/components/layout/TopNav.tsx | 92 +++ .../app/components/panels/AISuggestions.tsx | 2 +- .../components/panels/ActivityTimeline.tsx | 4 +- .../app/components/panels/AnatomyBrowser.tsx | 30 +- .../app/components/panels/CerebrumViewer.tsx | 4 +- .../app/components/panels/CronStatus.tsx | 21 +- .../app/components/panels/DesignQC.tsx | 56 -- .../app/components/panels/ProjectOverview.tsx | 216 +++++-- .../app/components/panels/TokenUsage.tsx | 192 ++++-- .../app/components/shared/DotBar.tsx | 71 +++ .../app/components/shared/LiveIndicator.tsx | 6 +- .../app/components/shared/StatTile.tsx | 36 ++ .../app/components/shared/StatusBadge.tsx | 52 +- .../app/components/shared/TokenBadge.tsx | 5 +- src/dashboard/app/hooks/useWolfData.ts | 119 +++- src/dashboard/app/index.html | 2 +- src/dashboard/app/lib/file-parsers.ts | 12 +- src/dashboard/app/lib/wolf-client.ts | 23 +- src/dashboard/app/styles/globals.css | 175 ++++-- src/designqc/designqc-capture.ts | 256 -------- src/designqc/designqc-engine.ts | 158 ----- src/designqc/designqc-types.ts | 34 -- src/hooks/anatomy-lock.ts | 104 ++++ src/hooks/anatomy-store.ts | 338 +++++++++++ src/hooks/post-read.ts | 19 +- src/hooks/post-write.ts | 124 ++-- src/hooks/pre-read.ts | 38 +- src/hooks/precompact.ts | 36 ++ src/hooks/session-start.ts | 224 ++++++- src/hooks/shared.ts | 168 ++++-- src/hooks/stop.ts | 121 +++- src/hooks/symbol-extractor.ts | 94 +++ src/scanner/anatomy-scanner.ts | 251 +++----- src/templates/OPENWOLF.md | 28 +- src/templates/STATUS.md | 64 ++ src/templates/claude-rules-openwolf.md | 3 +- src/templates/config.json | 20 +- src/templates/opencode-md-snippet.md | 5 + src/templates/opencode-plugin/anatomy.ts | 275 +++++++++ src/templates/opencode-plugin/fs.ts | 64 ++ src/templates/opencode-plugin/index.ts | 99 ++++ src/templates/opencode-plugin/post-read.ts | 57 ++ src/templates/opencode-plugin/post-write.ts | 270 +++++++++ src/templates/opencode-plugin/pre-read.ts | 63 ++ src/templates/opencode-plugin/pre-write.ts | 105 ++++ src/templates/opencode-plugin/session.ts | 89 +++ src/templates/opencode-plugin/stop.ts | 126 ++++ src/templates/opencode-plugin/types.ts | 41 ++ src/templates/reframe-frameworks.md | 85 ++- src/templates/skills/reframe.md | 41 ++ src/templates/skills/security-audit.md | 40 ++ src/utils/dashboard-auth.ts | 25 + src/utils/fs-safe.ts | 57 +- tests/anatomy-lock.test.ts | 76 +++ tests/anatomy-store.test.ts | 142 +++++ tests/security.test.ts | 105 ++++ tests/symbol-extractor.test.ts | 98 ++++ tests/token-measurement.test.ts | 38 ++ tsconfig.json | 12 +- 96 files changed, 6211 insertions(+), 2451 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 README.zh-CN.md create mode 100644 RELEASE_NOTES_2.0.0.md create mode 100644 assets/openwolf-dashboard.png create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/openwolf-check.mjs create mode 100644 src/agents/antigravity.ts create mode 100644 src/agents/codex.ts create mode 100644 src/agents/cursor.ts create mode 100644 src/agents/gemini.ts create mode 100644 src/agents/index.ts create mode 100644 src/agents/markers.ts create mode 100644 src/agents/opencode.ts create mode 100644 src/agents/skills.ts create mode 100644 src/agents/types.ts delete mode 100644 src/cli/codex-config.ts delete mode 100644 src/cli/designqc-cmd.ts delete mode 100644 src/cli/init.test.ts create mode 100644 src/cli/report.ts delete mode 100644 src/dashboard/app/components/layout/Header.tsx delete mode 100644 src/dashboard/app/components/layout/Sidebar.tsx create mode 100644 src/dashboard/app/components/layout/TopNav.tsx delete mode 100644 src/dashboard/app/components/panels/DesignQC.tsx create mode 100644 src/dashboard/app/components/shared/DotBar.tsx create mode 100644 src/dashboard/app/components/shared/StatTile.tsx delete mode 100644 src/designqc/designqc-capture.ts delete mode 100644 src/designqc/designqc-engine.ts delete mode 100644 src/designqc/designqc-types.ts create mode 100644 src/hooks/anatomy-lock.ts create mode 100644 src/hooks/anatomy-store.ts create mode 100644 src/hooks/precompact.ts create mode 100644 src/hooks/symbol-extractor.ts create mode 100644 src/templates/STATUS.md create mode 100644 src/templates/opencode-md-snippet.md create mode 100644 src/templates/opencode-plugin/anatomy.ts create mode 100644 src/templates/opencode-plugin/fs.ts create mode 100644 src/templates/opencode-plugin/index.ts create mode 100644 src/templates/opencode-plugin/post-read.ts create mode 100644 src/templates/opencode-plugin/post-write.ts create mode 100644 src/templates/opencode-plugin/pre-read.ts create mode 100644 src/templates/opencode-plugin/pre-write.ts create mode 100644 src/templates/opencode-plugin/session.ts create mode 100644 src/templates/opencode-plugin/stop.ts create mode 100644 src/templates/opencode-plugin/types.ts create mode 100644 src/templates/skills/reframe.md create mode 100644 src/templates/skills/security-audit.md create mode 100644 src/utils/dashboard-auth.ts create mode 100644 tests/anatomy-lock.test.ts create mode 100644 tests/anatomy-store.test.ts create mode 100644 tests/security.test.ts create mode 100644 tests/symbol-extractor.test.ts create mode 100644 tests/token-measurement.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..4dd12d4a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,190 @@ +# Changelog + +All notable changes to OpenWolf are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/), and OpenWolf uses +[Semantic Versioning](https://semver.org/). + +> This fork (`@alptech/openwolf`) keeps its own package version. Upstream +> feature history below is retained for reference; package version remains +> under the fork's numbering (currently 1.0.6). + +## [1.0.6] - 2026-08-12 + +### Changed + +- Synced business code from upstream [cytostack/openwolf](https://github.com/cytostack/openwolf) + (through 2.0.2 feature set): multi-agent adapters, PreCompact, durable + anatomy store, measured token usage, redesigned dashboard, bundled skills. +- Retired Design QC path in favor of upstream architecture. +- README rewritten for the synced feature set; added Chinese README and + upstream acknowledgments. Package name/author/version remain this fork's. + +## [2.0.2] - 2026-07-15 + +### Added + +- Antigravity agent adapter (beta, context-level via `AGENTS.md`). + `openwolf init --agent antigravity` and `--agent all` now include it, and + auto-detection picks it up when Antigravity is installed. + +### Changed + +- Documentation and website refreshed to reflect v2 throughout: the seven + lifecycle hooks including PreCompact, the durable anatomy store with + symbol-level reads, measured token usage, the redesigned dashboard, the + `/reframe` skill, and per-project dashboard ports. Retired Design QC + content removed. Positioning generalized across supported agents. + +## [2.0.1] - 2026-07-15 + +### Fixed + +- Dashboard no longer white-screens when the server rejects the token. A 401 + now renders a clear "token rejected" message with guidance instead of + crashing the page. Root cause: `StatusBadge` threw on an undefined status, + and failed API responses were being fed into component state. +- Multi-project port collisions resolved. Projects upgraded from 1.x all kept + the shared default dashboard and daemon ports, so only the first project's + dashboard would ever open. Three fixes work together: `openwolf update` + reassigns a free port pair when a project's ports collide with another + registered project, `openwolf dashboard` starts this project's server on a + free port when the configured one is held by another project's daemon + (instead of opening a URL that gets a 401), and the daemon accepts an + `OPENWOLF_DASHBOARD_PORT` override. Fresh installs already received unique + ports; this brings upgraded projects to parity. + +## [2.0.0] - 2026-07-15 + +OpenWolf 2.0 turns the second brain for Claude Code into a context layer for +every AI coding assistant, with verifiable token measurement, a hardened +security posture, and a re-architected project index. + +### Added + +Multi-agent support: + +- Agent adapter architecture: `openwolf init` now auto-detects the coding + agents installed on your machine and wires each of them to the same `.wolf/` + brain. Explicit control via `--agent codex opencode gemini cursor`, `--agent all`, + or `--agent claude` to opt out. +- Codex CLI integration: project-level lifecycle hooks via `.codex/hooks.json` + plus an `AGENTS.md` protocol block. +- OpenCode integration: a native plugin installed to `.opencode/plugin/` that + maps OpenCode tool events onto the `.wolf/` state. +- Gemini CLI integration: `GEMINI.md` protocol block. +- Cursor integration: an always-applied rule at `.cursor/rules/openwolf.mdc`. +- Protocol blocks are marker-fenced and idempotent: your own content in + `AGENTS.md` or `GEMINI.md` is never modified, and re-running init never + duplicates anything. + +Measured token usage: + +- The Stop hook reads real API usage from the harness transcript (input, + output, cache read, cache write tokens, and API call count) into the token + ledger. Estimates and measurements are reported side by side. +- New `openwolf report` command: estimated vs measured usage in the terminal. +- Per-agent session attribution: every ledger session records which agent ran it. + +Context management: + +- Session digest: at session start, a token-budget-capped digest of the most + valuable state (STATUS.md next phase, Do-Not-Repeat list, recent bug fixes, + anatomy pointer) is injected directly into the model's context. + Budgets are configurable per agent in `config.json`. +- Compaction survival: a new PreCompact lifecycle hook + snapshots in-flight session state, and session start after compaction + re-injects a digest of the files already modified. Session state is no + longer wiped on resume or compaction. +- Anatomy staleness detection: scans pin the git HEAD; if the HEAD moves or + the scan ages past the configured interval, the agent is told to rescan + before trusting the index. +- End-of-turn reminders now reach the model through the `additionalContext` + channel instead of invisible stderr. +- `STATUS.md` session handoff document: resume any session in one small read. + +Anatomy re-architecture: + +- Durable store: the source of truth for the project index moved from + `anatomy.md` itself to `.wolf/anatomy-index.json`, with `anatomy.md` + rendered from it. Concurrent writers now coordinate through a + cross-platform lock; simultaneous edits no longer lose entries. +- Version-skew safe: markdown written by older hooks or edited by hand is + detected by content hash and absorbed additively into the store. +- Symbol-level entries: files above 500 estimated tokens index their + top-level functions and classes with line ranges and per-slice token + estimates (TypeScript, JavaScript, Python, Go, Rust). The pre-read hint + points agents at exact line ranges so they can read one function with + offset/limit instead of the whole file. Hints are suppressed automatically + if the file on disk has changed since indexing. + +Skills and tooling: + +- Bundled skills installed on init for Claude Code, Codex, and OpenCode: + `/security-audit` (layered audit: dependencies, secrets, injection + surfaces, authorization, ranked report) and `/reframe` (framework + selection and migration plus a design audit/fix mode). +- `scripts/openwolf-check.mjs`: a standalone, read-only inspector that + reports whether OpenWolf is installed in a project, which agents are + wired, recency, and lifetime plus recent-session statistics. +- `openwolf update` now has parity with init: it creates missing files, + re-runs the recorded agent adapters, refreshes bundled skills, and + performs one-time data migrations, all after taking a timestamped backup. + +Dashboard 2.0: + +- Complete redesign: monochrome dot-matrix design system with a single + signal-red accent, top navigation, bento stat tiles, and hash-based deep + links to panels. +- Surfaces the 2.0 data: measured vs estimated tokens, cache economics, + per-agent breakdown table, wired-agents widget, context health (scan + freshness, pinned git HEAD, digest budget), and the STATUS.md handoff. +- Reliable Run Now for cron tasks over authenticated HTTP with visible + running/queued/failed feedback. + +### Changed + +- Reframe now leads with an anti-generic design mandate: a blocklist of the + recognizable AI-generated aesthetic plus positive principles, applied to + every framework migration prompt. Distinctiveness is an acceptance criterion. +- Astryx added as the 13th framework in the Reframe knowledge base. +- Contributors are credited in the README; detailed attribution lives in + commit trailers. +- STATUS.md template localized to English. + +### Fixed + +- CRLF line endings no longer wipe `anatomy.md` on Windows (#50, #24). +- Concurrent post-write hooks no longer lose anatomy entries. +- Old `config.json` files without newer sections no longer crash commands (#26, #27). +- `openwolf init` and `openwolf update` no longer reset per-project ports; + fresh projects get a free port pair automatically (#37, #38). +- `bug search` is null-safe across buglog schema drift (#44). +- `EPERM` on WSL2 with EFS-encrypted directories fixed via a copy shim (#33). +- Files outside the project root no longer pollute the index (#56). +- Documentation and config edits are no longer mislogged as bug fixes, and + auto-detection can be disabled (#28, #57). +- Dart language support in the scanner (#10). + +### Security + +- Dashboard binds to 127.0.0.1 by default and requires a per-project token + (timing-safe comparison) for all API and WebSocket access (#30, #34). +- Command injection eliminated: every dynamic process invocation uses + argument arrays; a shell-mode spawn was removed from the cron engine. +- Path traversal guards (realpath-based, symlink-safe) on cron AI task file access. +- File-watcher broadcasts capped at 1 MB to prevent memory abuse. +- Secret-bearing files (keys, keystores, credential files, `.npmrc`, and + more, not just `.env`) are excluded from all index and memory capture (#54). +- A security regression test suite runs with `pnpm test`, including a guard + test that fails the build if injectable process calls ever return. + +### Removed + +- Design QC screenshot capture (agents capture and read their own + screenshots now); the `puppeteer-core` dependency is gone. +- The unverifiable token comparison chart in the dashboard; only measured + numbers or clearly labeled estimates are shown. + +## [1.0.4] - 2026-03-20 + +Final 1.x release. Claude Code only. diff --git a/README.md b/README.md index 2cf5ee17..750fce7d 100644 --- a/README.md +++ b/README.md @@ -5,39 +5,67 @@

@alptech/openwolf

- A second brain for Claude Code and Codex.
- Project intelligence, token tracking, and invisible enforcement through 6 hook scripts. Zero workflow changes. + The second brain for Claude Code. Now for every AI coding assistant. +

+ +

+ Improved context management, optimized architecture scaffolding, and smarter token utilization,
+ delivered through 7 invisible lifecycle hooks. Zero workflow changes.

npm version License: AGPL-3.0 Node.js + Chinese README

-> **This is a fork of [openwolf](https://www.npmjs.com/package/openwolf) by [Cytostack](https://github.com/cytostack).** It preserves all original capabilities and adds native, first-class [Codex](https://github.com/openai/codex) integration alongside the existing Claude Code support. Both agents get equal treatment — dedicated hook scripts, project entry files, and config scaffolding. - ---- - -## Why OpenWolf Exists - -Claude Code is powerful but it works blind. It doesn't know what a file contains until it opens it. It can't tell a 50-token config from a 2,000-token module. It reads the same file multiple times in one session without noticing. It has no index of your project, no memory of your corrections, and no awareness of what it already tried. - -OpenWolf gives Claude a second brain: a file index so it knows what files contain before reading them, a learning memory that accumulates your preferences and past mistakes, and a token ledger that tracks everything. All through 6 invisible hook scripts that fire on every Claude action. +

+ Quick Start  ·  + Supported Agents  ·  + Context Management  ·  + Token Intelligence  ·  + Security  ·  + Dashboard  ·  + Changelog +

-## Token Comparison +> **This is a fork of [openwolf](https://github.com/cytostack/openwolf) by [Cytostack](https://github.com/cytostack).** It tracks upstream business features while keeping the `@alptech/openwolf` package identity. -Tested on a large active project. Same codebase, same prompts, different setups: +--- -``` -OpenClaw + Claude ██████████████████████████████████████ ~3.4M tokens -Claude CLI (no OpenWolf) ████████████████████████████████ ~2.5M tokens -OpenWolf + Claude CLI ████████ ~425K tokens -``` +| Without OpenWolf | With OpenWolf | +|---|---| +| The agent rereads a file it already saw (~2,000 tokens) | It reads the one-line description first, or skips the read entirely | +| Whole-file reads just to find one function | Symbol-level hints give exact line ranges for `offset`/`limit` reads | +| Context compaction wipes what the session did | A PreCompact snapshot and restore keep the work in context | +| Every agent starts from a cold prompt | One shared `.wolf/` brain across Codex, OpenCode, Claude Code, Cursor, and Antigravity | +| No idea where your tokens went | Usage measured from harness transcripts, plus a live local dashboard | -**OpenWolf saved ~80% of tokens** compared to bare Claude CLI on the same project. +--- -Across 20 projects, 132+ sessions: average token reduction of 65.8%, with 71% of repeated file reads caught and blocked. These are numbers from real usage, not benchmarks. Your results will vary by project size and usage patterns. +## Why OpenWolf? + +Coding agents are powerful but they work blind. An agent does not know what a +file contains until it opens it. It cannot tell a 50-token config from a +2,000-token module. It rereads the same file in one session without noticing, +forgets your corrections between sessions, and loses everything when its +context window compacts. + +OpenWolf gives your agent a second brain that fixes all of that: + +- **Context management.** A budget-capped digest of your project's most + valuable state (current goals, known mistakes, fixed bugs, the project map) + is injected at every session start. A PreCompact hook plus a + compaction-aware restart mean context compaction no longer erases what the + session already did. +- **Architecture scaffolding.** A durable, self-healing project index maps + every file with a description, a token estimate, and (for large files) its + functions and classes with exact line ranges. Agents navigate your codebase + instead of rediscovering it. +- **Token utilization.** Repeated reads are caught, whole-file reads become + targeted slice reads, and real usage is measured from harness transcripts + so you can verify the savings instead of trusting an estimate. ## Quick Start @@ -47,206 +75,229 @@ cd your-project openwolf init ``` -By default, `openwolf init` installs both Claude and Codex integration. +That is it. `init` auto-detects the coding agents installed on your machine +and wires each of them to the same `.wolf/` brain. Use your agents normally; +OpenWolf works underneath. + +## Supported Agents + +One `.wolf/` brain, many agents: + +| Agent | Integration | Depth | +|-------|-------------|-------| +| **Codex CLI** | `.codex/hooks.json` lifecycle hooks + `AGENTS.md` | Full (hooks + context) | +| **OpenCode** | Native plugin + `AGENTS.md` | Full (hooks + context) | +| **Claude Code** | 7 lifecycle hooks + `CLAUDE.md` | Full (hooks + context) | +| **Cursor** | `.cursor/rules/openwolf.mdc` (always applied) | Beta (context) | +| **Antigravity** | `AGENTS.md` protocol block | Beta (context) | +| **Gemini CLI** | `GEMINI.md` protocol block | Beta (context) | ```bash -openwolf init claude # only Claude integration -openwolf init codex # only Codex integration +openwolf init # auto-detect installed agents (recommended) +openwolf init --agent codex opencode # wire exactly these +openwolf init --agent all # wire every detected agent +openwolf init --agent claude # Claude Code only ``` -That's it. Use `claude` or `codex` normally. OpenWolf is watching. +Protocol blocks are marker-fenced: your own content in `AGENTS.md` or +`GEMINI.md` is never touched, and re-running init never duplicates anything. ## What It Creates -`openwolf init` creates a `.wolf/` directory in your project and installs agent-specific entry files: +`openwolf init` creates a `.wolf/` directory in your project: | File | Purpose | |------|---------| -| `anatomy.md` | Project file map with descriptions and token estimates | +| `anatomy-index.json` | Durable project index: descriptions, token estimates, content hashes, symbols | +| `anatomy.md` | Human-readable render of the index, kept in sync automatically | | `cerebrum.md` | Learned preferences, corrections, Do-Not-Repeat list | | `memory.md` | Chronological action log with token estimates | -| `buglog.json` | Bug fix memory, searchable, prevents re-discovery | -| `token-ledger.json` | Lifetime token tracking and session history | -| `hooks/` | Provider-specific hook scripts under `.wolf/hooks/claude/` and `.wolf/hooks/codex/` | -| `config.json` | Configuration with sensible defaults | -| `identity.md` | Agent persona for this project | -| `OPENWOLF.md` | Shared OpenWolf operating protocol | -| `CLAUDE.md` | Claude entry file that points Claude at `.wolf/OPENWOLF.md` | -| `AGENTS.md` | Codex entry file that points Codex at `.wolf/OPENWOLF.md` | - -When Claude integration is enabled, OpenWolf also writes `.claude/settings.json` and `.claude/rules/openwolf.md` to register hooks and rules. -When Codex integration is enabled, OpenWolf writes `.codex/hooks.json` and `.codex/config.toml` to register Codex hooks. +| `STATUS.md` | Session handoff: resume any session in one small read | +| `buglog.json` | Bug fix memory, searchable, prevents rediscovery | +| `token-ledger.json` | Estimated and measured token usage, per session and per agent | +| `hooks/` | 7 lifecycle hooks (pure Node.js, zero dependencies) | +| `config.json` | Configuration, including per-agent context budgets | +| `OPENWOLF.md` | The operating protocol your agents follow | ## How It Works -Before Claude reads a file, OpenWolf tells it what the file contains and how large it is. If Claude already read that file this session, OpenWolf warns it. Before Claude writes code, OpenWolf checks your `cerebrum.md` for known mistakes. After every write, it auto-updates the project map and logs token usage. You see none of this. It just happens. - ``` -You type a message - ↓ -Claude decides to read a file - ↓ -OpenWolf: "anatomy.md says this file is ~380 tokens. Description: Main entry point." - ↓ -Claude reads the file - ↓ -OpenWolf: logs the read, estimates tokens, checks for repeated reads - ↓ -Claude writes code - ↓ -OpenWolf: checks cerebrum.md - no known mistakes matched - ↓ -Claude finishes - ↓ -OpenWolf: updates anatomy.md, appends to memory.md, updates token ledger +Session starts + | +OpenWolf injects a token-budgeted digest: current goals, known mistakes, +recent bug fixes, project map pointer + | +Agent decides to read a big file + | +OpenWolf: "auth.ts (~2,900 tok). Symbols: validateToken L82-140 ~450 tok. +Read with offset/limit to fetch just the part you need." + | +Agent edits files + | +OpenWolf updates the index under a cross-process lock, logs the action, +estimates the cost + | +Context compacts mid-session + | +OpenWolf snapshots state before compaction and re-injects a digest of the +files already modified, so the agent does not redo finished work + | +Session ends + | +OpenWolf reads the real token usage from the transcript into the ledger ``` -## The .wolf/ Files - -
-cerebrum.md - the learning memory +## Context Management -Claude updates this file when you correct it, express a preference, or make a decision. The Do-Not-Repeat list prevents the same mistake across sessions. +- **Session digest.** The highest-value state is pushed into the model's + context at session start, capped to a configurable token budget per agent. + The model gets what it needs without reading six files. +- **Compaction survival.** The PreCompact hook snapshots in-flight session + state; after compaction the digest lists the files already modified with a + pointer to the action log. Resume and compaction no longer reset tracking. +- **Staleness detection.** Scans pin the git HEAD. If the HEAD moves or the + scan ages out, the agent is told to rescan before trusting the map. A wrong + index is never silently trusted. +- **STATUS.md handoff.** End-of-phase state lives in one small document, so a + fresh session reaches productive context in a single read. -```markdown -## Do-Not-Repeat +## Project Anatomy -- 2026-03-10: Never use `var` - always `const` or `let` -- 2026-03-11: Don't mock the database in integration tests - use the real connection -- 2026-03-14: The auth middleware reads from `cfg.talk`, not `cfg.tts` - got burned twice +The index is a durable store (`anatomy-index.json`) with a rendered, +human-readable view (`anatomy.md`). Writers coordinate through a +cross-process lock, so concurrent hook fires cannot lose entries. Edits made +to the markdown by hand or by older hook versions are detected by content +hash and absorbed additively. -## User Preferences +Files above 500 estimated tokens also index their top-level symbols: -- Prefers functional components over class components -- Always use named exports, never default exports -- Tests go in `__tests__/` next to the source file - -## Key Learnings - -- This project uses pnpm workspaces with strict hoisting -- The API rate limiter uses a sliding window, not fixed buckets -- Auth middleware reads from env.JWT_SECRET, not config file +``` +- `shared.ts` (~3,200 tok) + - fn `parseAnatomy` L82-104 (~180 tok) + - fn `serializeAnatomy` L106-129 (~200 tok) ``` -
+Before the agent reads a large file, the hint lists the biggest symbols with +line ranges so it can fetch one function with offset/limit instead of the +whole file. Hints are suppressed automatically if the file changed since +indexing; a stale range is never allowed to misdirect a read. +Languages with symbol support today: TypeScript, JavaScript, Python, Go, Rust. -
-anatomy.md - the project map +## Token Intelligence -Every file gets a description and token estimate. Claude reads this instead of opening files when the summary is enough. +Estimates are useful; measurements are trustworthy. At session end OpenWolf +reads the real usage from the harness transcript: input tokens, output +tokens, cache reads, cache writes, and API calls, attributed to the agent +that ran the session. -```markdown -## src/ +```bash +openwolf report +``` -- `index.ts` - Main entry point. Exports createProgram() for CLI. (~180 tok) -- `server.ts` - Express HTTP server with middleware chain. (~520 tok) +``` + Estimated (char-ratio heuristic) + Total tokens: 1,549,658 + Est. savings vs bare: 1,772,690 + + Measured (from harness transcripts) + API calls: 29 + Input tokens: 57,489 + Cache reads: 309,141 +``` -## src/api/ +Field results from 1.x deployments (20 projects, 132+ sessions) averaged a +65.8% estimated token reduction, with 71% of repeated file reads caught and +blocked. Those figures are heuristic estimates; measured numbers in 2.x let +you verify savings on your own workload. -- `auth.ts` - JWT validation middleware. Reads from env.JWT_SECRET. (~340 tok) -- `users.ts` - CRUD endpoints for /api/users. Pagination via query params. (~890 tok) -``` +## Security -
- -
-token-ledger.json - the receipt - -Every session gets a line item. Lifetime totals tell you if OpenWolf is actually saving tokens. - -```json -{ - "lifetime": { - "total_tokens_estimated": 503978, - "total_reads": 287, - "total_writes": 269, - "total_sessions": 15, - "anatomy_hits": 198, - "anatomy_misses": 89, - "repeated_reads_blocked": 106, - "estimated_savings_vs_bare_cli": 2066959 - } -} -``` +- Dashboard binds to 127.0.0.1 and requires a per-project token + (timing-safe comparison) for all API and WebSocket access. +- Every dynamic process invocation uses argument arrays; no shell + interpolation anywhere. +- Path traversal guards on all cron file access, realpath-based and symlink-safe. +- Secret-bearing files (keys, keystores, credential files, `.npmrc`, `.env` + and friends) never enter the index or the memory log. +- A security regression suite runs with `pnpm test`. -
+## Bundled Skills -
-buglog.json - the bug memory +`openwolf init` installs two slash commands into every configured agent +(Claude Code, Codex, OpenCode): -Before fixing anything, Claude checks if the fix is already known. After fixing, it logs the solution. +- **`/security-audit [scope]`**: layered audit covering dependencies, + secrets, injection surfaces, and authorization, ending in a + severity-ranked report wired into `.wolf/buglog.json`. +- **`/reframe [migrate | audit | fix]`**: the design brain. Pick or migrate a + UI framework using a curated knowledge base of 13 frameworks (shadcn/ui, + Aceternity, Magic UI, DaisyUI, HeroUI, Chakra, Flowbite, Preline, Park UI, + Origin UI, Headless UI, Cult UI, Astryx), or audit and fix existing UI + against an anti-generic design mandate. -```json -{ - "id": "bug-012", - "error_message": "TypeError: Cannot read properties of undefined (reading 'map')", - "file": "src/components/UserList.tsx", - "root_cause": "API response was null when users array was expected", - "fix": "Added optional chaining: data?.users?.map() and fallback empty array", - "tags": ["null-check", "api-response", "react"] -} +## Dashboard + +```bash +openwolf daemon start +openwolf dashboard ``` -
+A local, token-authenticated dashboard: measured vs estimated tokens, cache +economics, per-agent usage, context health, session handoff, live activity, +cron control, and the full anatomy browser with per-file symbols. ## Commands ``` -openwolf init [target] Initialize .wolf/ and install Claude/Codex integration -openwolf status Show health, stats, file integrity -openwolf scan Refresh the project structure map -openwolf scan --check Verify anatomy matches filesystem (exits 1 if stale) -openwolf dashboard Open the real-time web dashboard -openwolf daemon start Start background task scheduler -openwolf daemon stop Stop the scheduler -openwolf daemon restart Restart the scheduler -openwolf daemon logs View scheduler logs -openwolf cron list Show all scheduled tasks -openwolf cron run Trigger a task manually -openwolf cron retry Retry a dead-lettered task -openwolf designqc Capture full-page screenshots for design evaluation -openwolf bug search Search bug memory for known fixes -openwolf update Update all registered projects to latest version -openwolf restore [backup] Restore .wolf/ from a timestamped backup +openwolf init Initialize .wolf/ and wire detected agents +openwolf status Health, stats, file integrity +openwolf scan Rebuild the project index +openwolf scan --check Verify the index matches the filesystem (CI-friendly) +openwolf report Token report: estimated vs measured +openwolf dashboard Open the web dashboard +openwolf daemon start Start the background daemon +openwolf daemon stop Stop the daemon +openwolf cron list Scheduled tasks +openwolf cron run Trigger a task +openwolf bug search Search the bug memory +openwolf update Update every registered project (with backup) +openwolf restore [backup] Roll back .wolf/ from a timestamped backup ``` -## Design QC - -Capture full-page screenshots of your running app and let Claude evaluate the design. +There is also a standalone inspector that needs nothing installed: ```bash -openwolf designqc +node scripts/openwolf-check.mjs [projectDir] # read-only usage report ``` -Auto-detects your dev server, captures viewport-height JPEG sections of every route, and saves them to `.wolf/designqc-captures/`. Then tell Claude to read the screenshots and evaluate. Requires `puppeteer-core`. - -## Reframe - -Ask Claude to help you pick a UI framework. OpenWolf ships a curated knowledge base of 12 frameworks (shadcn/ui, Aceternity, Magic UI, DaisyUI, HeroUI, Chakra, Flowbite, Preline, Park UI, Origin UI, Headless UI, Cult UI) with battle-tested migration prompts. Claude reads `.wolf/reframe-frameworks.md`, asks you a few questions, and executes the migration with the right prompt for your project. - -## How OpenWolf Compares - -OpenWolf is not an AI wrapper. It is 6 hook scripts and a `.wolf/` directory. It doesn't run your AI for you or change your workflow. It gives Claude Code and Codex what they lack: a project map so they read less, a memory so they learn faster, and a ledger so you see where tokens go. - ## Requirements - Node.js 20+ -- Claude Code CLI or Codex +- At least one supported coding agent - Windows, macOS, or Linux -- Optional: PM2 for persistent background tasks -- Optional: `puppeteer-core` for Design QC screenshots +- Optional: PM2 for a persistent background daemon ## Limitations -- Claude Code hooks are a relatively new feature. OpenWolf falls back to `CLAUDE.md` instructions when hooks don't fire. -- Codex hooks require Codex project hook support via `.codex/hooks.json` and `.codex/config.toml`, plus `AGENTS.md` project instructions. -- Token tracking is estimation-based (character-to-token ratio), not exact API counts. Accurate to within ~15%. -- `cerebrum.md` depends on Claude following instructions to update it after corrections. Compliance is ~85-90%, not 100%. -- This is v1.0.5. Things may break. [File issues](https://github.com/nottyjay/openwolf/issues). +- Estimated figures use a character-ratio heuristic (accurate to roughly + 15%); measured figures come from harness transcripts and are exact. +- Hook coverage varies by agent: Claude Code and Codex have full lifecycle + hooks, OpenCode uses its plugin events, Gemini CLI and Cursor are + context-only integrations. +- Protocol compliance (updating cerebrum, logging bugs) depends on the model + following instructions; the hooks enforce what can be enforced and remind + about the rest. +- Found something broken? [File an issue](https://github.com/nottyjay/openwolf/issues). + +## Acknowledgments -## Origin Story +This project is based on the original **[OpenWolf](https://github.com/cytostack/openwolf)** +by [Cytostack](https://github.com/cytostack) / Farhan Palathinkal Afsal. +Thank you to the upstream authors and contributors for the architecture, +hooks, and ongoing improvements. This fork tracks upstream business features +while publishing as `@alptech/openwolf`. -We were building products with Claude Code at Cytostack when we noticed something off. Sessions were eating through tokens faster than they should. When we dug in, we found Claude re-reading the same files multiple times, scanning entire directories to find one function, and having no way to know what a file contained without opening it. There was no project map, no read awareness, no token visibility. So we built the tooling we wished existed -- a file index so Claude reads less, a learning memory so it gets smarter, and a ledger that tracks every token. That became OpenWolf. +Upstream repository: https://github.com/cytostack/openwolf ## License @@ -254,4 +305,5 @@ We were building products with Claude Code at Cytostack when we noticed somethin ## Author -Original project by Farhan Palathinkal Afsal — [Cytostack](https://github.com/cytostack). Forked and extended with first-class Codex integration by [@alptech](https://www.npmjs.com/package/@alptech/openwolf). +Original project by Farhan Palathinkal Afsal — [Cytostack](https://github.com/cytostack). +Maintained as `@alptech/openwolf` by [alptech](https://github.com/nottyjay) / [@nottyjay](https://github.com/nottyjay). diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 00000000..3243efc2 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,227 @@ +

+ OpenWolf demo +

+ +

@alptech/openwolf

+ +

+ Claude Code 的第二大脑。现已支持主流 AI 编程助手。 +

+ +

+ 更优的上下文管理、架构索引与 Token 利用,
+ 通过 7 个不可见生命周期 Hook 自动生效,零工作流改动。 +

+ +

+ npm version + License: AGPL-3.0 + Node.js + English README +

+ +> **本仓库是 [openwolf](https://github.com/cytostack/openwolf)([Cytostack](https://github.com/cytostack))的 fork。** 同步上游业务能力,并保留 `@alptech/openwolf` 包名与项目身份。 + +--- + +| 没有 OpenWolf | 有 OpenWolf | +|---|---| +| Agent 重复读取已看过的文件(约 2,000 tokens) | 先读一行描述,或直接跳过读取 | +| 为找一个函数读完整文件 | 符号级提示给出精确行号,支持 `offset`/`limit` | +| 上下文压缩会丢掉本轮工作 | PreCompact 快照与恢复,压缩后仍可续作 | +| 每个 Agent 都从冷启动开始 | 共享同一套 `.wolf/` 大脑(Codex / OpenCode / Claude Code / Cursor / Antigravity 等) | +| 不知道 Token 花在哪里 | 从 harness 转录读取实测用量,并提供本地 Dashboard | + +--- + +## 为什么需要 OpenWolf? + +编程 Agent 很强,但默认是“盲操作”:不知道文件内容与体量,会在同一次会话里重复读文件,跨会话忘记你的纠正,上下文压缩后又像失忆。 + +OpenWolf 提供第二大脑: + +- **上下文管理**:会话开始注入预算受限的高价值摘要;PreCompact + 压缩后重启,避免工作进度被抹掉。 +- **架构脚手架**:可自愈的项目索引,含描述、Token 估算,大文件还有函数/类与行号范围。 +- **Token 利用**:拦截重复读取,整文件读变切片读,并从 harness 转录测量真实用量。 + +## 快速开始 + +```bash +npm install -g @alptech/openwolf +cd your-project +openwolf init +``` + +`init` 会自动检测本机已安装的编程 Agent,并接入同一套 `.wolf/` 大脑。之后照常使用 Agent 即可。 + +## 支持的 Agent + +| Agent | 集成方式 | 深度 | +|-------|----------|------| +| **Codex CLI** | `.codex/hooks.json` 生命周期 Hook + `AGENTS.md` | 完整(Hook + 上下文) | +| **OpenCode** | 原生插件 + `AGENTS.md` | 完整(Hook + 上下文) | +| **Claude Code** | 7 个生命周期 Hook + `CLAUDE.md` | 完整(Hook + 上下文) | +| **Cursor** | `.cursor/rules/openwolf.mdc`(始终应用) | Beta(上下文) | +| **Antigravity** | `AGENTS.md` 协议块 | Beta(上下文) | +| **Gemini CLI** | `GEMINI.md` 协议块 | Beta(上下文) | + +```bash +openwolf init # 自动检测已安装 Agent(推荐) +openwolf init --agent codex opencode # 只接入指定 Agent +openwolf init --agent all # 接入全部可检测 Agent +openwolf init --agent claude # 仅 Claude Code +``` + +协议块使用 marker 围栏:不会改动你自己写在 `AGENTS.md` / `GEMINI.md` 中的内容,重复执行 `init` 也不会重复插入。 + +## 会创建什么 + +`openwolf init` 会在项目中创建 `.wolf/`: + +| 文件 | 作用 | +|------|------| +| `anatomy-index.json` | 持久化项目索引:描述、Token 估算、内容哈希、符号 | +| `anatomy.md` | 索引的可读渲染,自动保持同步 | +| `cerebrum.md` | 学习记忆:偏好、纠正、Do-Not-Repeat | +| `memory.md` | 按时间顺序的操作日志与 Token 估算 | +| `STATUS.md` | 会话交接:少量阅读即可恢复进度 | +| `buglog.json` | Bug 修复记忆,可搜索,避免重复踩坑 | +| `token-ledger.json` | 估算与实测 Token 用量(按会话/Agent) | +| `hooks/` | 7 个生命周期 Hook(纯 Node.js,零依赖) | +| `config.json` | 配置,含各 Agent 上下文预算 | +| `OPENWOLF.md` | Agent 需遵循的操作协议 | + +## 工作原理 + +``` +会话开始 + | +OpenWolf 注入受 Token 预算限制的摘要:当前目标、已知错误、近期修复、项目地图指针 + | +Agent 准备读取大文件 + | +OpenWolf: "auth.ts (~2,900 tok)。符号: validateToken L82-140 ~450 tok。 +可用 offset/limit 只读需要的部分。" + | +Agent 编辑文件 + | +OpenWolf 在跨进程锁下更新索引、记录动作、估算成本 + | +会话中发生上下文压缩 + | +OpenWolf 在压缩前快照,压缩后重新注入已修改文件摘要,避免重复劳动 + | +会话结束 + | +OpenWolf 从转录中读取真实 Token 用量写入 ledger +``` + +## 上下文管理 + +- **会话摘要**:在可配置的每 Agent Token 预算内注入最高价值状态。 +- **压缩存活**:PreCompact 快照 + 压缩后摘要,续作不再从零开始。 +- **过期检测**:扫描会钉住 git HEAD;HEAD 变动或扫描过期时会提示重扫。 +- **STATUS.md 交接**:阶段结束状态集中在一个小文件,新会话一次读取即可恢复。 + +## 项目 Anatomy + +索引以 `anatomy-index.json` 持久化,并以 `anatomy.md` 可读呈现。写操作通过跨进程锁协调;对手写或旧版 Hook 修改的 markdown,会按内容哈希做增量吸收。 + +估算超过 500 tokens 的文件还会索引顶层符号: + +``` +- `shared.ts` (~3,200 tok) + - fn `parseAnatomy` L82-104 (~180 tok) + - fn `serializeAnatomy` L106-129 (~200 tok) +``` + +当前支持符号提取的语言:TypeScript、JavaScript、Python、Go、Rust。 + +## Token 智能 + +估算有用,测量更可信。会话结束时 OpenWolf 从 harness 转录读取真实用量:input / output / cache read / cache write / API 调用次数,并归属到对应 Agent。 + +```bash +openwolf report +``` + +1.x 实战数据(20 个项目、132+ 会话)平均估算节省约 65.8% Token,71% 的重复读被拦截。2.x 起可用实测数据在你自己的工作负载上验证。 + +## 安全 + +- Dashboard 仅绑定 `127.0.0.1`,API/WebSocket 使用每项目 token(时序安全比较) +- 动态进程调用一律参数数组,无 shell 拼接 +- Cron 文件访问有路径穿越防护(realpath,符号链接安全) +- 密钥类文件(keys、keystore、credential、`.npmrc`、`.env` 等)不进入索引与 memory +- `pnpm test` 包含安全回归套件 + +## 内置 Skills + +`openwolf init` 会为已配置 Agent(Claude Code、Codex、OpenCode)安装: + +- **`/security-audit [scope]`**:依赖、密钥、注入面、鉴权等分层审计,结果写入 `.wolf/buglog.json` +- **`/reframe [migrate | audit | fix]`**:UI 框架选型/迁移与反“AI 味”设计审计(含 13 个框架知识库) + +## Dashboard + +```bash +openwolf daemon start +openwolf dashboard +``` + +本地、需 token 认证的控制台:估算 vs 实测 Token、缓存经济、按 Agent 用量、上下文健康、会话交接、实时活动、cron 控制、带符号的 anatomy 浏览器。 + +## 命令 + +``` +openwolf init 初始化 .wolf/ 并接入检测到的 Agent +openwolf status 健康状态、统计、文件完整性 +openwolf scan 重建项目索引 +openwolf scan --check 校验索引是否与文件系统一致(适合 CI) +openwolf report Token 报告:估算 vs 实测 +openwolf dashboard 打开 Web Dashboard +openwolf daemon start 启动后台 daemon +openwolf daemon stop 停止 daemon +openwolf cron list 查看定时任务 +openwolf cron run 手动触发任务 +openwolf bug search 搜索 bug 记忆 +openwolf update 更新所有已注册项目(带备份) +openwolf restore [backup] 从时间戳备份回滚 .wolf/ +``` + +也可使用无需安装的只读检查脚本: + +```bash +node scripts/openwolf-check.mjs [projectDir] +``` + +## 环境要求 + +- Node.js 20+ +- 至少一个受支持的编程 Agent +- Windows / macOS / Linux +- 可选:PM2(持久化后台 daemon) + +## 局限 + +- 估算基于字符比启发式(大约 ±15%);实测来自 harness 转录,更精确 +- 不同 Agent 的 Hook 覆盖不同:Claude Code / Codex 完整,OpenCode 走插件事件,Gemini / Cursor 主要为上下文级 +- cerebrum / buglog 的协议遵从仍依赖模型执行指令;Hook 负责可强制的部分 +- 发现问题请提交:[Issues](https://github.com/nottyjay/openwolf/issues) + +## 致谢 + +本项目基于 **[OpenWolf](https://github.com/cytostack/openwolf)** 原项目,作者为 +[Cytostack](https://github.com/cytostack) / Farhan Palathinkal Afsal。 +感谢上游作者与贡献者提供的架构、Hook 体系与持续改进。本 fork 同步上游业务功能,并以 `@alptech/openwolf` 发布。 + +上游仓库:https://github.com/cytostack/openwolf + +## 许可证 + +[AGPL-3.0](LICENSE) + +## 作者 + +原项目作者:Farhan Palathinkal Afsal — [Cytostack](https://github.com/cytostack)。 +本 fork 维护方:`@alptech/openwolf` — [alptech](https://github.com/nottyjay) / [@nottyjay](https://github.com/nottyjay)。 diff --git a/RELEASE_NOTES_2.0.0.md b/RELEASE_NOTES_2.0.0.md new file mode 100644 index 00000000..6272316c --- /dev/null +++ b/RELEASE_NOTES_2.0.0.md @@ -0,0 +1,63 @@ +# OpenWolf 2.0.0 + +The second brain for Claude Code is now a context layer for every AI coding assistant. + +OpenWolf 2.0 is a ground-up upgrade focused on three things: improved context +management, optimized architecture scaffolding, and smarter token utilization. +It integrates 10 community pull requests (every author is credited in the +README), closes 12 issues, and ships the first test suite for the project's +most load-bearing internals. + +## Highlights + +**Works with your whole toolbox.** `openwolf init` auto-detects the coding +agents installed on your machine and wires each one to the same `.wolf/` +brain: Claude Code and Codex get full lifecycle hooks, OpenCode gets a native +plugin, Gemini CLI and Cursor get protocol files. One project memory, shared +by every assistant you use. + +**Tokens you can verify.** OpenWolf now reads real API usage from harness +transcripts at the end of every session: input, output, and cache tokens, +per agent. `openwolf report` and the dashboard show measured numbers next to +the estimates instead of asking you to trust a heuristic. + +**Context that survives.** A budget-capped digest of your project's most +valuable state is injected at session start. A new PreCompact hook plus a +compaction-aware session start mean context compaction no longer erases what +the session already did. STATUS.md gives every session a one-read handoff. + +**A project index that cannot corrupt itself.** The anatomy index moved to a +durable store guarded by a cross-process lock; concurrent edits no longer +lose entries, and markdown edits by older hooks or humans are absorbed +instead of fought. Big files now index their functions and classes with line +ranges, so agents read one function with offset/limit instead of the whole file. + +**Hardened by default.** The dashboard binds to localhost with token +authentication, command injection surfaces are gone, path traversal is +blocked, and secret-bearing files never enter the index. + +**A dashboard worth opening.** Complete redesign: dot-matrix numerals, +monochrome surfaces with a single signal red, and panels for measured +tokens, per-agent usage, context health, and session handoff. + +## Breaking and behavioral changes + +- `openwolf init` wires all detected agents by default. Use `--agent claude` + for the previous Claude-only behavior. +- The dashboard now requires a token (printed by `openwolf dashboard`, + stored at `.wolf/dashboard-token`). Bookmarks without it will see 401. +- Design QC was removed; agents capture their own screenshots now, and the + `puppeteer-core` dependency is gone. +- `.wolf/anatomy-index.json` is the new source of truth for the project + index. `anatomy.md` is rendered from it and remains fully readable. + Existing projects are migrated automatically by `openwolf update`. + +## Upgrading + +```bash +npm install -g openwolf@2 +openwolf update # every registered project: backup, migrate, rewire +``` + +Full details in CHANGELOG.md. Community contributions are credited in the +README Contributors section. diff --git a/assets/openwolf-dashboard.png b/assets/openwolf-dashboard.png new file mode 100644 index 0000000000000000000000000000000000000000..54dd76ba3c5018f87fc4e2e091df335956e3013f GIT binary patch literal 102743 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 P3_#%N>gTe~DWM4f%##&H literal 0 HcmV?d00001 diff --git a/package.json b/package.json index 73d7479b..4e7cf6a4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@alptech/openwolf", "version": "1.0.6", - "description": "Token-conscious AI brain for Claude Code projects", + "description": "The second brain for Claude Code, now for every AI coding assistant: context management, architecture scaffolding, and smarter token utilization through lifecycle hooks.", "type": "module", "bin": { "openwolf": "./dist/bin/openwolf.js" @@ -14,25 +14,25 @@ "dev": "tsc --watch", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", - "prepublishOnly": "pnpm build" + "prepublishOnly": "pnpm build", + "test": "node --experimental-strip-types --test tests/*.test.ts" }, "dependencies": { "chalk": "^5.3.0", "chokidar": "^4.0.0", "commander": "^12.0.0", - "express": "^5.0.0", - "node-cron": "^3.0.3", + "express": "^5.2.1", + "node-cron": "^4.2.1", "open": "^10.0.0", "ws": "^8.18.0" }, "optionalDependencies": { - "puppeteer-core": "^24.39.1" + "puppeteer-core": "^24.42.0" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", "@types/express": "^5.0.0", "@types/node": "^22.0.0", - "@types/node-cron": "^3.0.11", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@types/ws": "^8.5.12", @@ -67,12 +67,15 @@ "openwolf", "claude-code", "codex-cli", + "opencode", + "cursor", "developer-tools" ], "files": [ "dist/", "src/templates/", "LICENSE", - "README.md" + "README.md", + "README.zh-CN.md" ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff914078..0b5e8fb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,48 +18,39 @@ importers: specifier: ^12.0.0 version: 12.1.0 express: - specifier: ^5.0.0 + specifier: ^5.2.1 version: 5.2.1 - glob: - specifier: ^11.0.0 - version: 11.1.0 node-cron: - specifier: ^3.0.3 - version: 3.0.3 + specifier: ^4.2.1 + version: 4.6.0 open: specifier: ^10.0.0 version: 10.2.0 - puppeteer-core: - specifier: ^24.39.1 - version: 24.39.1 ws: specifier: ^8.18.0 version: 8.19.0 devDependencies: '@tailwindcss/vite': specifier: ^4.0.0 - version: 4.2.1(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)) + version: 4.2.1(vite@6.4.3(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)) '@types/express': specifier: ^5.0.0 version: 5.0.6 '@types/node': specifier: ^22.0.0 version: 22.19.15 - '@types/node-cron': - specifier: ^3.0.11 - version: 3.0.11 '@types/react': specifier: ^19.0.0 version: 19.2.14 '@types/react-dom': specifier: ^19.0.0 - version: 19.2.3(@types/react@19.2.14) + version: 19.2.4(@types/react@19.2.14) '@types/ws': specifier: ^8.5.12 version: 8.18.1 '@vitejs/plugin-react': specifier: ^4.0.0 - version: 4.7.0(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)) + version: 4.7.0(vite@6.4.3(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)) react: specifier: ^19.0.0 version: 19.2.4 @@ -77,10 +68,14 @@ importers: version: 5.9.3 vite: specifier: ^6.0.0 - version: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1) + version: 6.4.3(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1) vitepress: specifier: ^1.6.4 version: 1.6.4(@algolia/client-search@5.49.1)(@types/node@22.19.15)(@types/react@19.2.14)(lightningcss@1.31.1)(postcss@8.5.8)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(search-insights@2.17.3)(typescript@5.9.3) + optionalDependencies: + puppeteer-core: + specifier: ^24.42.0 + version: 24.43.1 packages: @@ -570,10 +565,6 @@ packages: '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - '@isaacs/cliui@9.0.0': - resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} - engines: {node: '>=18'} - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -590,8 +581,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@puppeteer/browsers@2.13.0': - resolution: {integrity: sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==} + '@puppeteer/browsers@2.13.2': + resolution: {integrity: sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==} engines: {node: '>=18'} hasBin: true @@ -929,9 +920,6 @@ packages: '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} - '@types/node-cron@3.0.11': - resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} - '@types/node@22.19.15': resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} @@ -941,8 +929,8 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} peerDependencies: '@types/react': ^19.2.0 @@ -1103,12 +1091,8 @@ packages: react-native-b4a: optional: true - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - bare-events@2.8.2: - resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} peerDependencies: bare-abort-controller: '*' peerDependenciesMeta: @@ -1131,6 +1115,9 @@ packages: bare-path@3.0.0: resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + bare-stream@2.8.1: resolution: {integrity: sha512-bSeR8RfvbRwDpD7HWZvn8M3uYNDrk7m9DQjYOFkENZlXW8Ju/MPaqUPQq5LqJ3kyjEm07siTaAQ7wBKCU59oHg==} peerDependencies: @@ -1142,8 +1129,8 @@ packages: bare-events: optional: true - bare-url@2.3.2: - resolution: {integrity: sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==} + bare-url@2.5.1: + resolution: {integrity: sha512-cD5ciQuKlx+eumTCfqbfiL+fhQm+dHbVNB/cX4+d+I/nx4JsVop9VoFEPAs5RJ6I84QR6bZIBjpd2kjDOYeWcg==} baseline-browser-mapping@2.10.0: resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} @@ -1161,10 +1148,6 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} - engines: {node: 18 || 20 || >=22} - browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -1236,14 +1219,18 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} - content-disposition@1.0.1: - resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} engines: {node: '>=18'} content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1259,10 +1246,6 @@ packages: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -1357,8 +1340,8 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - devtools-protocol@0.0.1581282: - resolution: {integrity: sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==} + devtools-protocol@0.0.1608973: + resolution: {integrity: sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==} dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -1402,8 +1385,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} esbuild@0.21.5: @@ -1489,10 +1472,6 @@ packages: focus-trap@7.8.0: resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1533,12 +1512,6 @@ packages: resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} engines: {node: '>= 14'} - glob@11.1.0: - resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} - engines: {node: 20 || >=22} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1550,8 +1523,8 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} hast-util-to-html@9.0.5: @@ -1589,8 +1562,8 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + ip-address@10.5.0: + resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} engines: {node: '>= 12'} ipaddr.js@1.9.1: @@ -1622,13 +1595,6 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - jackspeak@4.2.3: - resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} - engines: {node: 20 || >=22} - jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -1727,10 +1693,6 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - lru-cache@11.2.6: - resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} - engines: {node: 20 || >=22} - lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1751,8 +1713,8 @@ packages: mdast-util-to-hast@13.2.1: resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} - media-typer@1.1.0: - resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} engines: {node: '>= 0.8'} merge-descriptors@2.0.0: @@ -1782,14 +1744,6 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} - engines: {node: 18 || 20 || >=22} - - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - minisearch@7.2.0: resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} @@ -1799,8 +1753,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -1808,13 +1762,13 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - netmask@2.0.2: - resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} + netmask@2.1.1: + resolution: {integrity: sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==} engines: {node: '>= 0.4.0'} - node-cron@3.0.3: - resolution: {integrity: sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==} - engines: {node: '>=6.0.0'} + node-cron@4.6.0: + resolution: {integrity: sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==} + engines: {node: '>=20'} node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} @@ -1849,21 +1803,10 @@ packages: resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} engines: {node: '>= 14'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-scurry@2.0.2: - resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} - engines: {node: 18 || 20 || >=22} - path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -1911,16 +1854,16 @@ packages: pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - puppeteer-core@24.39.1: - resolution: {integrity: sha512-AMqQIKoEhPS6CilDzw0Gd1brLri3emkC+1N2J6ZCCuY1Cglo56M63S0jOeBZDQlemOiRd686MYVMl9ELJBzN3A==} + puppeteer-core@24.43.1: + resolution: {integrity: sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==} engines: {node: '>=18'} qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} raw-body@3.0.2: @@ -2030,19 +1973,11 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - shiki@2.5.0: resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -2053,14 +1988,10 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -2069,8 +2000,8 @@ packages: resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} engines: {node: '>= 14'} - socks@2.8.7: - resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} source-map-js@1.2.1: @@ -2092,8 +2023,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - streamx@2.23.0: - resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} @@ -2149,12 +2080,12 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} - typed-query-selector@2.12.1: - resolution: {integrity: sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==} + typed-query-selector@2.12.2: + resolution: {integrity: sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==} typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} @@ -2189,10 +2120,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - uuid@8.3.2: - resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} - hasBin: true - vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -2237,8 +2164,8 @@ packages: terser: optional: true - vite@6.4.1: - resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: @@ -2300,11 +2227,6 @@ packages: webdriver-bidi-protocol@0.4.1: resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==} - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -2324,6 +2246,18 @@ packages: utf-8-validate: optional: true + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -2339,8 +2273,8 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} yauzl@2.10.0: @@ -2760,8 +2694,6 @@ snapshots: '@iconify/types@2.0.0': {} - '@isaacs/cliui@9.0.0': {} - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2781,7 +2713,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@puppeteer/browsers@2.13.0': + '@puppeteer/browsers@2.13.2': dependencies: debug: 4.4.3 extract-zip: 2.0.1 @@ -2789,12 +2721,13 @@ snapshots: proxy-agent: 6.5.0 semver: 7.7.4 tar-fs: 3.1.2 - yargs: 17.7.2 + yargs: 17.7.3 transitivePeerDependencies: - bare-abort-controller - bare-buffer - react-native-b4a - supports-color + optional: true '@rolldown/pluginutils@1.0.0-beta.27': {} @@ -2974,14 +2907,15 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 - '@tailwindcss/vite@4.2.1(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1))': + '@tailwindcss/vite@4.2.1(vite@6.4.3(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1))': dependencies: '@tailwindcss/node': 4.2.1 '@tailwindcss/oxide': 4.2.1 tailwindcss: 4.2.1 - vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1) + vite: 6.4.3(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1) - '@tootallnate/quickjs-emscripten@0.23.0': {} + '@tootallnate/quickjs-emscripten@0.23.0': + optional: true '@types/babel__core@7.20.5': dependencies: @@ -3071,8 +3005,6 @@ snapshots: '@types/mdurl@2.0.0': {} - '@types/node-cron@3.0.11': {} - '@types/node@22.19.15': dependencies: undici-types: 6.21.0 @@ -3081,7 +3013,7 @@ snapshots: '@types/range-parser@1.2.7': {} - '@types/react-dom@19.2.3(@types/react@19.2.14)': + '@types/react-dom@19.2.4(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -3113,7 +3045,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1))': + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -3121,7 +3053,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1) + vite: 6.4.3(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1) transitivePeerDependencies: - supports-color @@ -3234,7 +3166,8 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 - agent-base@7.1.4: {} + agent-base@7.1.4: + optional: true algoliasearch@5.49.1: dependencies: @@ -3253,56 +3186,68 @@ snapshots: '@algolia/requester-fetch': 5.49.1 '@algolia/requester-node-http': 5.49.1 - ansi-regex@5.0.1: {} + ansi-regex@5.0.1: + optional: true ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + optional: true ast-types@0.13.4: dependencies: tslib: 2.8.1 + optional: true - b4a@1.8.0: {} - - balanced-match@4.0.4: {} + b4a@1.8.0: + optional: true - bare-events@2.8.2: {} + bare-events@2.9.1: + optional: true bare-fs@4.5.5: dependencies: - bare-events: 2.8.2 - bare-path: 3.0.0 - bare-stream: 2.8.1(bare-events@2.8.2) - bare-url: 2.3.2 + bare-events: 2.9.1 + bare-path: 3.1.1 + bare-stream: 2.8.1(bare-events@2.9.1) + bare-url: 2.5.1 fast-fifo: 1.3.2 transitivePeerDependencies: - bare-abort-controller - react-native-b4a + optional: true - bare-os@3.8.0: {} + bare-os@3.8.0: + optional: true bare-path@3.0.0: dependencies: bare-os: 3.8.0 + optional: true + + bare-path@3.1.1: + optional: true - bare-stream@2.8.1(bare-events@2.8.2): + bare-stream@2.8.1(bare-events@2.9.1): dependencies: - streamx: 2.23.0 + streamx: 2.28.0 teex: 1.0.1 optionalDependencies: - bare-events: 2.8.2 + bare-events: 2.9.1 transitivePeerDependencies: - bare-abort-controller - react-native-b4a + optional: true - bare-url@2.3.2: + bare-url@2.5.1: dependencies: - bare-path: 3.0.0 + bare-path: 3.1.1 + optional: true baseline-browser-mapping@2.10.0: {} - basic-ftp@5.2.0: {} + basic-ftp@5.2.0: + optional: true birpc@2.9.0: {} @@ -3316,14 +3261,10 @@ snapshots: on-finished: 2.4.1 qs: 6.15.0 raw-body: 3.0.2 - type-is: 2.0.1 + type-is: 2.1.0 transitivePeerDependencies: - supports-color - brace-expansion@5.0.4: - dependencies: - balanced-match: 4.0.4 - browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.10.0 @@ -3332,7 +3273,8 @@ snapshots: node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) - buffer-crc32@0.2.13: {} + buffer-crc32@0.2.13: + optional: true bundle-name@4.1.0: dependencies: @@ -3364,34 +3306,40 @@ snapshots: dependencies: readdirp: 4.1.2 - chromium-bidi@14.0.0(devtools-protocol@0.0.1581282): + chromium-bidi@14.0.0(devtools-protocol@0.0.1608973): dependencies: - devtools-protocol: 0.0.1581282 + devtools-protocol: 0.0.1608973 mitt: 3.0.1 zod: 3.25.76 + optional: true cliui@8.0.1: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + optional: true clsx@2.1.1: {} color-convert@2.0.1: dependencies: color-name: 1.1.4 + optional: true - color-name@1.1.4: {} + color-name@1.1.4: + optional: true comma-separated-tokens@2.0.3: {} commander@12.1.0: {} - content-disposition@1.0.1: {} + content-disposition@1.1.0: {} content-type@1.0.5: {} + content-type@2.0.0: {} + convert-source-map@2.0.0: {} cookie-signature@1.2.2: {} @@ -3402,12 +3350,6 @@ snapshots: dependencies: is-what: 5.5.0 - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - csstype@3.2.3: {} d3-array@3.2.4: @@ -3448,7 +3390,8 @@ snapshots: d3-timer@3.0.1: {} - data-uri-to-buffer@6.0.2: {} + data-uri-to-buffer@6.0.2: + optional: true debug@4.4.3: dependencies: @@ -3470,6 +3413,7 @@ snapshots: ast-types: 0.13.4 escodegen: 2.1.0 esprima: 4.0.1 + optional: true depd@2.0.0: {} @@ -3481,7 +3425,8 @@ snapshots: dependencies: dequal: 2.0.3 - devtools-protocol@0.0.1581282: {} + devtools-protocol@0.0.1608973: + optional: true dom-helpers@5.2.1: dependencies: @@ -3500,13 +3445,15 @@ snapshots: emoji-regex-xs@1.0.0: {} - emoji-regex@8.0.0: {} + emoji-regex@8.0.0: + optional: true encodeurl@2.0.0: {} end-of-stream@1.4.5: dependencies: once: 1.4.0 + optional: true enhanced-resolve@5.20.0: dependencies: @@ -3519,7 +3466,7 @@ snapshots: es-errors@1.3.0: {} - es-object-atoms@1.1.1: + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -3589,14 +3536,18 @@ snapshots: esutils: 2.0.3 optionalDependencies: source-map: 0.6.1 + optional: true - esprima@4.0.1: {} + esprima@4.0.1: + optional: true - estraverse@5.3.0: {} + estraverse@5.3.0: + optional: true estree-walker@2.0.2: {} - esutils@2.0.3: {} + esutils@2.0.3: + optional: true etag@1.8.1: {} @@ -3604,15 +3555,16 @@ snapshots: events-universal@1.0.1: dependencies: - bare-events: 2.8.2 + bare-events: 2.9.1 transitivePeerDependencies: - bare-abort-controller + optional: true express@5.2.1: dependencies: accepts: 2.0.0 body-parser: 2.2.2 - content-disposition: 1.0.1 + content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 @@ -3631,12 +3583,12 @@ snapshots: parseurl: 1.3.3 proxy-addr: 2.0.7 qs: 6.15.0 - range-parser: 1.2.1 + range-parser: 1.3.0 router: 2.2.0 send: 1.2.1 serve-static: 2.2.1 statuses: 2.0.2 - type-is: 2.0.1 + type-is: 2.1.0 vary: 1.1.2 transitivePeerDependencies: - supports-color @@ -3650,14 +3602,17 @@ snapshots: '@types/yauzl': 2.10.3 transitivePeerDependencies: - supports-color + optional: true fast-equals@5.4.0: {} - fast-fifo@1.3.2: {} + fast-fifo@1.3.2: + optional: true fd-slicer@1.1.0: dependencies: pend: 1.2.0 + optional: true fdir@6.5.0(picomatch@4.0.3): optionalDependencies: @@ -3678,11 +3633,6 @@ snapshots: dependencies: tabbable: 6.4.0 - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - forwarded@0.2.0: {} fresh@2.0.0: {} @@ -3694,29 +3644,31 @@ snapshots: gensync@1.0.0-beta.2: {} - get-caller-file@2.0.5: {} + get-caller-file@2.0.5: + optional: true get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 function-bind: 1.1.2 get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-stream@5.2.0: dependencies: pump: 3.0.4 + optional: true get-uri@6.0.5: dependencies: @@ -3725,15 +3677,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color - - glob@11.1.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 4.2.3 - minimatch: 10.2.4 - minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 2.0.2 + optional: true gopd@1.2.0: {} @@ -3741,7 +3685,7 @@ snapshots: has-symbols@1.1.0: {} - hasown@2.0.2: + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -3781,6 +3725,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true https-proxy-agent@7.0.6: dependencies: @@ -3788,6 +3733,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true iconv-lite@0.7.2: dependencies: @@ -3797,13 +3743,15 @@ snapshots: internmap@2.0.3: {} - ip-address@10.1.0: {} + ip-address@10.5.0: + optional: true ipaddr.js@1.9.1: {} is-docker@3.0.0: {} - is-fullwidth-code-point@3.0.0: {} + is-fullwidth-code-point@3.0.0: + optional: true is-inside-container@1.0.0: dependencies: @@ -3817,12 +3765,6 @@ snapshots: dependencies: is-inside-container: 1.0.0 - isexe@2.0.0: {} - - jackspeak@4.2.3: - dependencies: - '@isaacs/cliui': 9.0.0 - jiti@2.6.1: {} js-tokens@4.0.0: {} @@ -3886,13 +3828,12 @@ snapshots: dependencies: js-tokens: 4.0.0 - lru-cache@11.2.6: {} - lru-cache@5.1.1: dependencies: yallist: 3.1.1 - lru-cache@7.18.3: {} + lru-cache@7.18.3: + optional: true magic-string@0.30.21: dependencies: @@ -3914,7 +3855,7 @@ snapshots: unist-util-visit: 5.1.0 vfile: 6.0.3 - media-typer@1.1.0: {} + media-typer@1.1.1: {} merge-descriptors@2.0.0: {} @@ -3941,27 +3882,20 @@ snapshots: dependencies: mime-db: 1.54.0 - minimatch@10.2.4: - dependencies: - brace-expansion: 5.0.4 - - minipass@7.1.3: {} - minisearch@7.2.0: {} mitt@3.0.1: {} ms@2.1.3: {} - nanoid@3.3.11: {} + nanoid@3.3.18: {} negotiator@1.0.0: {} - netmask@2.0.2: {} + netmask@2.1.1: + optional: true - node-cron@3.0.3: - dependencies: - uuid: 8.3.2 + node-cron@4.6.0: {} node-releases@2.0.36: {} @@ -4002,26 +3936,20 @@ snapshots: socks-proxy-agent: 8.0.5 transitivePeerDependencies: - supports-color + optional: true pac-resolver@7.0.1: dependencies: degenerator: 5.0.1 - netmask: 2.0.2 - - package-json-from-dist@1.0.1: {} + netmask: 2.1.1 + optional: true parseurl@1.3.3: {} - path-key@3.1.1: {} - - path-scurry@2.0.2: - dependencies: - lru-cache: 11.2.6 - minipass: 7.1.3 - path-to-regexp@8.3.0: {} - pend@1.2.0: {} + pend@1.2.0: + optional: true perfect-debounce@1.0.0: {} @@ -4031,13 +3959,14 @@ snapshots: postcss@8.5.8: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 preact@10.28.4: {} - progress@2.0.3: {} + progress@2.0.3: + optional: true prop-types@15.8.1: dependencies: @@ -4064,23 +3993,26 @@ snapshots: socks-proxy-agent: 8.0.5 transitivePeerDependencies: - supports-color + optional: true - proxy-from-env@1.1.0: {} + proxy-from-env@1.1.0: + optional: true pump@3.0.4: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + optional: true - puppeteer-core@24.39.1: + puppeteer-core@24.43.1: dependencies: - '@puppeteer/browsers': 2.13.0 - chromium-bidi: 14.0.0(devtools-protocol@0.0.1581282) + '@puppeteer/browsers': 2.13.2 + chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) debug: 4.4.3 - devtools-protocol: 0.0.1581282 - typed-query-selector: 2.12.1 + devtools-protocol: 0.0.1608973 + typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.1 - ws: 8.19.0 + ws: 8.21.3 transitivePeerDependencies: - bare-abort-controller - bare-buffer @@ -4088,12 +4020,13 @@ snapshots: - react-native-b4a - supports-color - utf-8-validate + optional: true qs@6.15.0: dependencies: - side-channel: 1.1.0 + side-channel: 1.1.1 - range-parser@1.2.1: {} + range-parser@1.3.0: {} raw-body@3.0.2: dependencies: @@ -4161,7 +4094,8 @@ snapshots: dependencies: regex-utilities: 2.3.0 - require-directory@2.1.1: {} + require-directory@2.1.1: + optional: true rfdc@1.4.1: {} @@ -4216,7 +4150,8 @@ snapshots: semver@6.3.1: {} - semver@7.7.4: {} + semver@7.7.4: + optional: true send@1.2.1: dependencies: @@ -4229,7 +4164,7 @@ snapshots: mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 - range-parser: 1.2.1 + range-parser: 1.3.0 statuses: 2.0.2 transitivePeerDependencies: - supports-color @@ -4245,12 +4180,6 @@ snapshots: setprototypeof@1.2.0: {} - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - shiki@2.5.0: dependencies: '@shikijs/core': 2.5.0 @@ -4262,7 +4191,7 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 - side-channel-list@1.0.0: + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -4282,30 +4211,31 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.0: + side-channel@1.1.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.0 + side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - signal-exit@4.1.0: {} - - smart-buffer@4.2.0: {} + smart-buffer@4.2.0: + optional: true socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 debug: 4.4.3 - socks: 2.8.7 + socks: 2.8.9 transitivePeerDependencies: - supports-color + optional: true - socks@2.8.7: + socks@2.8.9: dependencies: - ip-address: 10.1.0 + ip-address: 10.5.0 smart-buffer: 4.2.0 + optional: true source-map-js@1.2.1: {} @@ -4318,7 +4248,7 @@ snapshots: statuses@2.0.2: {} - streamx@2.23.0: + streamx@2.28.0: dependencies: events-universal: 1.0.1 fast-fifo: 1.3.2 @@ -4326,12 +4256,14 @@ snapshots: transitivePeerDependencies: - bare-abort-controller - react-native-b4a + optional: true string-width@4.2.3: dependencies: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + optional: true stringify-entities@4.0.4: dependencies: @@ -4341,6 +4273,7 @@ snapshots: strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 + optional: true superjson@2.2.6: dependencies: @@ -4363,30 +4296,34 @@ snapshots: - bare-abort-controller - bare-buffer - react-native-b4a + optional: true tar-stream@3.1.8: dependencies: b4a: 1.8.0 bare-fs: 4.5.5 fast-fifo: 1.3.2 - streamx: 2.23.0 + streamx: 2.28.0 transitivePeerDependencies: - bare-abort-controller - bare-buffer - react-native-b4a + optional: true teex@1.0.1: dependencies: - streamx: 2.23.0 + streamx: 2.28.0 transitivePeerDependencies: - bare-abort-controller - react-native-b4a + optional: true text-decoder@1.2.7: dependencies: b4a: 1.8.0 transitivePeerDependencies: - react-native-b4a + optional: true tiny-invariant@1.3.3: {} @@ -4399,15 +4336,17 @@ snapshots: trim-lines@3.0.1: {} - tslib@2.8.1: {} + tslib@2.8.1: + optional: true - type-is@2.0.1: + type-is@2.1.0: dependencies: - content-type: 1.0.5 - media-typer: 1.1.0 + content-type: 2.0.0 + media-typer: 1.1.1 mime-types: 3.0.2 - typed-query-selector@2.12.1: {} + typed-query-selector@2.12.2: + optional: true typescript@5.9.3: {} @@ -4444,8 +4383,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - uuid@8.3.2: {} - vary@1.1.2: {} vfile-message@4.0.3: @@ -4485,7 +4422,7 @@ snapshots: fsevents: 2.3.3 lightningcss: 1.31.1 - vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1): + vite@6.4.3(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -4558,33 +4495,36 @@ snapshots: optionalDependencies: typescript: 5.9.3 - webdriver-bidi-protocol@0.4.1: {} - - which@2.0.2: - dependencies: - isexe: 2.0.0 + webdriver-bidi-protocol@0.4.1: + optional: true wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 string-width: 4.2.3 strip-ansi: 6.0.1 + optional: true wrappy@1.0.2: {} ws@8.19.0: {} + ws@8.21.3: + optional: true + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 - y18n@5.0.8: {} + y18n@5.0.8: + optional: true yallist@3.1.1: {} - yargs-parser@21.1.1: {} + yargs-parser@21.1.1: + optional: true - yargs@17.7.2: + yargs@17.7.3: dependencies: cliui: 8.0.1 escalade: 3.2.0 @@ -4593,12 +4533,15 @@ snapshots: string-width: 4.2.3 y18n: 5.0.8 yargs-parser: 21.1.1 + optional: true yauzl@2.10.0: dependencies: buffer-crc32: 0.2.13 fd-slicer: 1.1.0 + optional: true - zod@3.25.76: {} + zod@3.25.76: + optional: true zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..5ed0b5af --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/scripts/openwolf-check.mjs b/scripts/openwolf-check.mjs new file mode 100644 index 00000000..39b11b83 --- /dev/null +++ b/scripts/openwolf-check.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +/** + * openwolf-check — standalone, zero-dependency, read-only. + * + * Run from a project root (or pass a path) to see whether OpenWolf is + * installed there, which agents are wired, when it was last used, and what + * it did. Works without OpenWolf installed — it only reads files. + * + * node openwolf-check.mjs [projectDir] [--json] + */ +import * as fs from "node:fs"; +import * as path from "node:path"; + +const args = process.argv.slice(2); +const asJson = args.includes("--json"); +const root = path.resolve(args.find((a) => !a.startsWith("--")) ?? "."); +const wolfDir = path.join(root, ".wolf"); + +const read = (p) => { try { return fs.readFileSync(p, "utf-8"); } catch { return null; } }; +const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, "utf-8")); } catch { return null; } }; +const mtime = (p) => { try { return fs.statSync(p).mtimeMs; } catch { return null; } }; +const exists = (p) => fs.existsSync(p); + +function ago(ms) { + if (ms == null) return "never"; + const s = Math.floor((Date.now() - ms) / 1000); + if (s < 60) return `${s}s ago`; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return `${Math.floor(s / 86400)}d ago`; +} + +// ── Installed? ─────────────────────────────────────────────────────────────── +const report = { root, installed: exists(wolfDir) }; +if (!report.installed) { + if (asJson) { console.log(JSON.stringify(report, null, 2)); process.exit(1); } + console.log(`\n openwolf-check — ${root}`); + console.log(` ✗ No .wolf/ directory: OpenWolf is not initialized here.\n`); + process.exit(1); +} + +// ── Generation + hooks ────────────────────────────────────────────────────── +const hookFiles = (() => { try { return fs.readdirSync(path.join(wolfDir, "hooks")).filter((f) => f.endsWith(".js")); } catch { return []; } })(); +report.hooks = hookFiles; +report.generation = hookFiles.includes("precompact.js") ? "2.x" : hookFiles.length > 0 ? "1.x" : "unknown"; + +// ── Which agents are wired ────────────────────────────────────────────────── +const cfg = readJson(path.join(wolfDir, "config.json")); +const settings = read(path.join(root, ".claude", "settings.json")) ?? ""; +const agents = { + claude: settings.includes(".wolf/hooks/"), + codex: exists(path.join(root, ".codex", "hooks.json")), + opencode: exists(path.join(root, ".opencode", "plugin", "openwolf.ts")), + gemini: (read(path.join(root, "GEMINI.md")) ?? "").includes("openwolf:begin"), + cursor: exists(path.join(root, ".cursor", "rules", "openwolf.mdc")), +}; +report.agentsWired = Object.entries(agents).filter(([, v]) => v).map(([k]) => k); +report.agentsInConfig = cfg?.openwolf?.agents ?? null; +report.skills = ["security-audit", "reframe"].filter((s) => exists(path.join(root, ".claude", "commands", `${s}.md`))); + +// ── Recency: newest activity across the state files ───────────────────────── +const activityFiles = ["memory.md", "token-ledger.json", "buglog.json", "anatomy.md", "anatomy-index.json", path.join("hooks", "_session.json")]; +const newest = activityFiles + .map((f) => ({ f, t: mtime(path.join(wolfDir, f)) })) + .filter((x) => x.t != null) + .sort((a, b) => b.t - a.t)[0] ?? null; +report.lastActivity = newest ? { file: newest.f, at: new Date(newest.t).toISOString(), ago: ago(newest.t) } : null; + +// ── What it did: ledger sessions ──────────────────────────────────────────── +const ledger = readJson(path.join(wolfDir, "token-ledger.json")); +const lt = ledger?.lifetime ?? {}; +report.lifetime = { + sessions: lt.total_sessions ?? 0, + reads: lt.total_reads ?? 0, + writes: lt.total_writes ?? 0, + repeated_reads_blocked: lt.repeated_reads_blocked ?? 0, + estimated_tokens: lt.total_tokens_estimated ?? 0, + estimated_saved: lt.estimated_savings_vs_bare_cli ?? 0, + measured_tokens: (lt.real_input_tokens ?? 0) + (lt.real_output_tokens ?? 0) || null, +}; +report.recentSessions = (ledger?.sessions ?? []).slice(-3).map((s) => ({ + ended: s.ended, agent: s.agent ?? "claude", + reads: s.totals?.reads_count ?? 0, writes: s.totals?.writes_count ?? 0, + est_tokens: (s.totals?.input_tokens_estimated ?? 0) + (s.totals?.output_tokens_estimated ?? 0), + measured_tokens: s.real_usage ? s.real_usage.input_tokens + s.real_usage.output_tokens : null, +})); + +// ── What it did: last actions from memory.md ──────────────────────────────── +const memory = read(path.join(wolfDir, "memory.md")) ?? ""; +report.recentActions = memory.split("\n") + .filter((l) => /^\|\s*[\d:]+/.test(l)) + .slice(-6) + .map((l) => l.split("|").map((c) => c.trim()).filter(Boolean).slice(0, 3).join(" — ")); + +const bugs = readJson(path.join(wolfDir, "buglog.json")); +report.bugsLogged = bugs?.bugs?.length ?? 0; + +// ── Output ─────────────────────────────────────────────────────────────────── +if (asJson) { console.log(JSON.stringify(report, null, 2)); process.exit(0); } + +const line = (k, v) => console.log(` ${k.padEnd(24)} ${v}`); +console.log(`\n openwolf-check — ${root}\n`); +line("installed", `yes (hooks generation ${report.generation}, ${hookFiles.length} hook scripts)`); +line("agents wired", report.agentsWired.length ? report.agentsWired.join(", ") : "none detected"); +if (report.agentsInConfig) line("agents in config", report.agentsInConfig.join(", ")); +line("bundled skills", report.skills.length ? report.skills.map((s) => `/${s}`).join(", ") : "none (pre-2.0 install)"); +line("last activity", report.lastActivity ? `${report.lastActivity.ago} (${report.lastActivity.file})` : "never"); +console.log(""); +line("sessions", String(report.lifetime.sessions)); +line("reads / writes", `${report.lifetime.reads} / ${report.lifetime.writes}`); +line("re-reads blocked", String(report.lifetime.repeated_reads_blocked)); +line("est. tokens / saved", `${report.lifetime.estimated_tokens.toLocaleString()} / ${report.lifetime.estimated_saved.toLocaleString()}`); +line("measured tokens", report.lifetime.measured_tokens ? report.lifetime.measured_tokens.toLocaleString() : "none recorded (pre-2.0 or no sessions ended)"); +line("bugs logged", String(report.bugsLogged)); + +if (report.recentSessions.length) { + console.log("\n recent sessions"); + for (const s of report.recentSessions) { + line(` ${s.ended?.slice(0, 16) ?? "?"}`, `${s.agent} · ${s.reads}r/${s.writes}w · est ${s.est_tokens.toLocaleString()}${s.measured_tokens ? ` · measured ${s.measured_tokens.toLocaleString()}` : ""}`); + } +} +if (report.recentActions.length) { + console.log("\n last actions (memory.md)"); + for (const a of report.recentActions) console.log(` ${a}`); +} +console.log(""); diff --git a/src/agents/antigravity.ts b/src/agents/antigravity.ts new file mode 100644 index 00000000..f25781ce --- /dev/null +++ b/src/agents/antigravity.ts @@ -0,0 +1,20 @@ +import * as path from "node:path"; +import { upsertMarkerBlock } from "./markers.js"; +import { readSnippet } from "./index.js"; +import type { AgentAdapter, AgentInstallContext, AgentInstallResult } from "./types.js"; + +// Antigravity integration (beta). Antigravity reads AGENTS.md as its project +// context file, so the OpenWolf protocol block there carries the integration. +// Context-level like Gemini and Cursor: no dedicated lifecycle hooks yet. + +export const antigravityAdapter: AgentAdapter = { + name: "antigravity", + displayName: "Antigravity", + install(ctx: AgentInstallContext): AgentInstallResult { + const actions: string[] = []; + if (upsertMarkerBlock(path.join(ctx.projectRoot, "AGENTS.md"), readSnippet(ctx.templatesDir))) { + actions.push("AGENTS.md updated (OpenWolf block, Antigravity beta)"); + } + return { actions, warnings: [] }; + }, +}; diff --git a/src/agents/codex.ts b/src/agents/codex.ts new file mode 100644 index 00000000..f4dbd671 --- /dev/null +++ b/src/agents/codex.ts @@ -0,0 +1,79 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { upsertMarkerBlock } from "./markers.js"; +import { readSnippet } from "./index.js"; +import type { AgentAdapter, AgentInstallContext, AgentInstallResult } from "./types.js"; + +// Codex integration, adapted from PR #36 by @nottyjay (closes #2). +// Codex discovers project-level hooks from /.codex/hooks.json when +// `[features] hooks = true` is set, and reads AGENTS.md as its context file. +// The hook scripts themselves are the same provider-agnostic .wolf/hooks/*.js +// used for Claude Code (they resolve the project root via getProjectDir()). + +function hookEntry(projectRoot: string, script: string, timeout: number, statusMessage: string) { + return { + type: "command", + command: `node "${path.join(projectRoot, ".wolf", "hooks", script)}"`, + timeout, + statusMessage, + }; +} + +function buildCodexHooks(projectRoot: string) { + return { + hooks: { + SessionStart: [ + { matcher: "startup|resume|clear", hooks: [hookEntry(projectRoot, "session-start.js", 5, "OpenWolf session bootstrap")] }, + ], + PreToolUse: [ + { matcher: "Read", hooks: [hookEntry(projectRoot, "pre-read.js", 5, "OpenWolf read precheck")] }, + { matcher: "Edit|Write|MultiEdit|apply_patch", hooks: [hookEntry(projectRoot, "pre-write.js", 5, "OpenWolf write precheck")] }, + ], + PostToolUse: [ + { matcher: "Read", hooks: [hookEntry(projectRoot, "post-read.js", 5, "OpenWolf read tracking")] }, + { matcher: "Edit|Write|MultiEdit|apply_patch", hooks: [hookEntry(projectRoot, "post-write.js", 10, "OpenWolf anatomy update")] }, + ], + PreCompact: [ + { matcher: "", hooks: [hookEntry(projectRoot, "precompact.js", 5, "OpenWolf compaction snapshot")] }, + ], + Stop: [ + { matcher: "", hooks: [hookEntry(projectRoot, "stop.js", 10, "OpenWolf session wrap-up")] }, + ], + }, + }; +} + +export const codexAdapter: AgentAdapter = { + name: "codex", + displayName: "Codex CLI", + install(ctx: AgentInstallContext): AgentInstallResult { + const actions: string[] = []; + const warnings: string[] = []; + const codexDir = path.join(ctx.projectRoot, ".codex"); + fs.mkdirSync(codexDir, { recursive: true }); + + // 1. Register hooks + const hooksPath = path.join(codexDir, "hooks.json"); + fs.writeFileSync(hooksPath, JSON.stringify(buildCodexHooks(ctx.projectRoot), null, 2) + "\n", "utf-8"); + actions.push("Codex hooks registered (.codex/hooks.json)"); + + // 2. Enable the hooks feature — but never corrupt an existing config.toml. + const configPath = path.join(codexDir, "config.toml"); + if (!fs.existsSync(configPath)) { + fs.writeFileSync(configPath, "[features]\nhooks = true\n", "utf-8"); + actions.push("Codex hooks feature enabled (.codex/config.toml)"); + } else { + const existing = fs.readFileSync(configPath, "utf-8"); + if (!/hooks\s*=\s*true/.test(existing)) { + warnings.push('add "hooks = true" under [features] in .codex/config.toml'); + } + } + + // 3. Context file + if (upsertMarkerBlock(path.join(ctx.projectRoot, "AGENTS.md"), readSnippet(ctx.templatesDir))) { + actions.push("AGENTS.md updated (OpenWolf block)"); + } + + return { actions, warnings }; + }, +}; diff --git a/src/agents/cursor.ts b/src/agents/cursor.ts new file mode 100644 index 00000000..111bdb8e --- /dev/null +++ b/src/agents/cursor.ts @@ -0,0 +1,26 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { readSnippet } from "./index.js"; +import type { AgentAdapter, AgentInstallContext, AgentInstallResult } from "./types.js"; + +// Cursor integration (closes #12). Cursor reads project rules from +// .cursor/rules/*.mdc; `alwaysApply: true` injects the OpenWolf protocol +// into every conversation for this project. + +export const cursorAdapter: AgentAdapter = { + name: "cursor", + displayName: "Cursor", + install(ctx: AgentInstallContext): AgentInstallResult { + const rulesDir = path.join(ctx.projectRoot, ".cursor", "rules"); + fs.mkdirSync(rulesDir, { recursive: true }); + const body = `--- +description: OpenWolf project protocol (context management via .wolf/) +alwaysApply: true +--- + +${readSnippet(ctx.templatesDir).trim()} +`; + fs.writeFileSync(path.join(rulesDir, "openwolf.mdc"), body, "utf-8"); + return { actions: ["Cursor rule installed (.cursor/rules/openwolf.mdc)"], warnings: [] }; + }, +}; diff --git a/src/agents/gemini.ts b/src/agents/gemini.ts new file mode 100644 index 00000000..c1a76573 --- /dev/null +++ b/src/agents/gemini.ts @@ -0,0 +1,21 @@ +import * as path from "node:path"; +import { upsertMarkerBlock } from "./markers.js"; +import { readSnippet } from "./index.js"; +import type { AgentAdapter, AgentInstallContext, AgentInstallResult } from "./types.js"; + +// Gemini CLI integration (closes #22). Gemini CLI reads GEMINI.md as its +// project context file; it has no lifecycle-hook system comparable to +// Claude Code/Codex, so the protocol instructions carry the integration. +// (Approach from PR #39 by @ChasLui.) + +export const geminiAdapter: AgentAdapter = { + name: "gemini", + displayName: "Gemini CLI", + install(ctx: AgentInstallContext): AgentInstallResult { + const actions: string[] = []; + if (upsertMarkerBlock(path.join(ctx.projectRoot, "GEMINI.md"), readSnippet(ctx.templatesDir))) { + actions.push("GEMINI.md updated (OpenWolf block)"); + } + return { actions, warnings: [] }; + }, +}; diff --git a/src/agents/index.ts b/src/agents/index.ts new file mode 100644 index 00000000..3eac3556 --- /dev/null +++ b/src/agents/index.ts @@ -0,0 +1,98 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { execFileSync } from "node:child_process"; +import type { AgentAdapter } from "./types.js"; +import { codexAdapter } from "./codex.js"; +import { opencodeAdapter } from "./opencode.js"; +import { geminiAdapter } from "./gemini.js"; +import { cursorAdapter } from "./cursor.js"; +import { antigravityAdapter } from "./antigravity.js"; + +export type { AgentAdapter, AgentInstallContext, AgentInstallResult } from "./types.js"; + +// "claude" is not in this registry: the Claude Code integration is OpenWolf's +// native install path in cli/init.ts. This registry holds the additional +// agents wired up via `openwolf init --agent `. +const ADAPTERS: Record = { + [codexAdapter.name]: codexAdapter, + [opencodeAdapter.name]: opencodeAdapter, + [geminiAdapter.name]: geminiAdapter, + [cursorAdapter.name]: cursorAdapter, + [antigravityAdapter.name]: antigravityAdapter, +}; + +export function availableAgents(): string[] { + return Object.keys(ADAPTERS); +} + +function onPath(bin: string): boolean { + try { + execFileSync(process.platform === "win32" ? "where" : "which", [bin], { + stdio: "ignore", timeout: 2000, + }); + return true; + } catch { + return false; + } +} + +/** + * Which additional agents are actually present on this machine — used by + * `openwolf init` (no --agent flag) to auto-wire only what the user runs. + * An agent counts as installed if its config directory exists or its CLI + * is on PATH; Cursor is an app, so its app bundle also counts on macOS. + */ +export function detectInstalledAgents(): string[] { + const home = os.homedir(); + const detected: string[] = []; + if (fs.existsSync(path.join(home, ".codex")) || onPath("codex")) detected.push("codex"); + if (fs.existsSync(path.join(home, ".config", "opencode")) || onPath("opencode")) detected.push("opencode"); + if (fs.existsSync(path.join(home, ".gemini")) || onPath("gemini")) detected.push("gemini"); + if ( + fs.existsSync(path.join(home, ".cursor")) || + (process.platform === "darwin" && fs.existsSync("/Applications/Cursor.app")) + ) detected.push("cursor"); + if ( + fs.existsSync(path.join(home, ".antigravity")) || + fs.existsSync(path.join(home, ".config", "antigravity")) || + (process.platform === "darwin" && fs.existsSync("/Applications/Antigravity.app")) + ) detected.push("antigravity"); + return detected; +} + +export function resolveAgents(names: string[]): AgentAdapter[] { + const seen = new Set(); + const result: AgentAdapter[] = []; + for (const raw of names) { + const name = raw.toLowerCase().trim(); + if (name === "claude" || seen.has(name)) continue; // claude is always installed + const adapter = name === "all" ? null : ADAPTERS[name]; + if (name === "all") { + for (const a of Object.values(ADAPTERS)) { + if (!seen.has(a.name)) { seen.add(a.name); result.push(a); } + } + continue; + } + if (!adapter) { + throw new Error(`Unknown agent "${raw}". Valid agents: claude, ${availableAgents().join(", ")}, all`); + } + seen.add(name); + result.push(adapter); + } + return result; +} + +/** Shared OpenWolf context snippet injected into AGENTS.md / GEMINI.md / rules. */ +export function readSnippet(templatesDir: string): string { + const p = path.join(templatesDir, "agents-md-snippet.md"); + try { + return fs.readFileSync(p, "utf-8"); + } catch { + return `# OpenWolf + +@.wolf/OPENWOLF.md + +This project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md every session. Check .wolf/cerebrum.md before generating code. Check .wolf/anatomy.md before reading files.`; + } +} diff --git a/src/agents/markers.ts b/src/agents/markers.ts new file mode 100644 index 00000000..4466e132 --- /dev/null +++ b/src/agents/markers.ts @@ -0,0 +1,33 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +const BEGIN = ""; +const END = ""; + +/** + * Insert or replace the OpenWolf-managed block in a markdown context file + * (AGENTS.md, GEMINI.md, …). Everything outside the markers is user content + * and is preserved byte-for-byte. Returns true if the file changed. + */ +export function upsertMarkerBlock(filePath: string, content: string): boolean { + const block = `${BEGIN}\n${content.trim()}\n${END}`; + let existing = ""; + try { + existing = fs.readFileSync(filePath, "utf-8"); + } catch {} + + let next: string; + if (existing.includes(BEGIN) && existing.includes(END)) { + const pattern = new RegExp(`${BEGIN}[\\s\\S]*?${END}`); + next = existing.replace(pattern, block); + } else if (existing.trim().length > 0) { + next = existing.replace(/\s*$/, "\n\n") + block + "\n"; + } else { + next = block + "\n"; + } + + if (next === existing) return false; + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, next, "utf-8"); + return true; +} diff --git a/src/agents/opencode.ts b/src/agents/opencode.ts new file mode 100644 index 00000000..dd9821b9 --- /dev/null +++ b/src/agents/opencode.ts @@ -0,0 +1,50 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { safeCopyFile } from "../utils/fs-safe.js"; +import { upsertMarkerBlock } from "./markers.js"; +import { readSnippet } from "./index.js"; +import type { AgentAdapter, AgentInstallContext, AgentInstallResult } from "./types.js"; + +// OpenCode integration, adapted from PR #9 by @alfasin (closes #5, #6). +// OpenCode loads plugins from .opencode/plugin/*.ts. We install the multi-file +// plugin under .opencode/plugin/openwolf/ plus a top-level entry that +// re-exports it, so the (non-recursive) plugin loader picks up exactly one +// module. The plugin maps OpenCode's native hooks (tool.execute.before/after, +// session events) onto the same .wolf/ files the Claude/Codex hooks maintain. + +const ENTRY = `// OpenWolf plugin entry — installed by \`openwolf init --agent opencode\`. +// Implementation lives in ./openwolf/ so it can stay multi-file; this entry +// is the only module OpenCode's plugin loader instantiates. +export { OpenWolf } from "./openwolf/index.js" +`; + +export const opencodeAdapter: AgentAdapter = { + name: "opencode", + displayName: "OpenCode", + install(ctx: AgentInstallContext): AgentInstallResult { + const actions: string[] = []; + const warnings: string[] = []; + + const pluginSrcDir = path.join(ctx.templatesDir, "opencode-plugin"); + if (!fs.existsSync(pluginSrcDir)) { + warnings.push("opencode-plugin templates missing from the OpenWolf install — plugin not written"); + return { actions, warnings }; + } + + const pluginDir = path.join(ctx.projectRoot, ".opencode", "plugin"); + const destDir = path.join(pluginDir, "openwolf"); + fs.mkdirSync(destDir, { recursive: true }); + for (const file of fs.readdirSync(pluginSrcDir)) { + if (!file.endsWith(".ts")) continue; + safeCopyFile(path.join(pluginSrcDir, file), path.join(destDir, file)); + } + fs.writeFileSync(path.join(pluginDir, "openwolf.ts"), ENTRY, "utf-8"); + actions.push("OpenCode plugin installed (.opencode/plugin/openwolf.ts)"); + + if (upsertMarkerBlock(path.join(ctx.projectRoot, "AGENTS.md"), readSnippet(ctx.templatesDir))) { + actions.push("AGENTS.md updated (OpenWolf block)"); + } + + return { actions, warnings }; + }, +}; diff --git a/src/agents/skills.ts b/src/agents/skills.ts new file mode 100644 index 00000000..66d07c2f --- /dev/null +++ b/src/agents/skills.ts @@ -0,0 +1,43 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { safeCopyFile } from "../utils/fs-safe.js"; + +// Bundled skills (Workstream H): shipped as markdown command templates and +// installed into each agent's project-level command surface on init. +// Claude Code → .claude/commands/.md (slash command, $ARGUMENTS) +// OpenCode → .opencode/command/.md (custom command, $ARGUMENTS) +// Codex → .codex/prompts/.md (custom prompt) +// Gemini CLI and Cursor have no project-level command surface we target yet. + +const SKILLS = ["security-audit", "reframe"]; + +export function installSkills(projectRoot: string, templatesDir: string, agents: string[]): string[] { + const skillsDir = path.join(templatesDir, "skills"); + if (!fs.existsSync(skillsDir)) return []; + + const destinations: Array<{ agent: string; dir: string }> = [ + { agent: "claude", dir: path.join(projectRoot, ".claude", "commands") }, + ]; + if (agents.includes("opencode")) { + destinations.push({ agent: "opencode", dir: path.join(projectRoot, ".opencode", "command") }); + } + if (agents.includes("codex")) { + destinations.push({ agent: "codex", dir: path.join(projectRoot, ".codex", "prompts") }); + } + + const actions: string[] = []; + for (const { agent, dir } of destinations) { + fs.mkdirSync(dir, { recursive: true }); + let installed = 0; + for (const skill of SKILLS) { + const src = path.join(skillsDir, `${skill}.md`); + if (!fs.existsSync(src)) continue; + safeCopyFile(src, path.join(dir, `${skill}.md`)); + installed++; + } + if (installed > 0) { + actions.push(`Skills installed for ${agent}: ${SKILLS.map((s) => `/${s}`).join(", ")}`); + } + } + return actions; +} diff --git a/src/agents/types.ts b/src/agents/types.ts new file mode 100644 index 00000000..0ae700be --- /dev/null +++ b/src/agents/types.ts @@ -0,0 +1,27 @@ +// Agent adapter architecture (OpenWolf 2.0, Workstream C). +// Design adopted from PR #39 by @ChasLui; concrete integrations from +// PR #36 (@nottyjay, Codex) and PR #9 (@alfasin, OpenCode). + +export interface AgentInstallContext { + projectRoot: string; + wolfDir: string; + templatesDir: string; +} + +export interface AgentInstallResult { + /** Human-readable lines describing what was written, for init output. */ + actions: string[]; + /** Manual steps the user still has to do, if any. */ + warnings: string[]; +} + +export interface AgentAdapter { + /** Registry key used with `openwolf init --agent `. */ + name: string; + displayName: string; + /** + * Wire this agent up to the project's .wolf/ brain. Must be idempotent: + * re-running init must never duplicate blocks or clobber user config. + */ + install(ctx: AgentInstallContext): AgentInstallResult; +} diff --git a/src/buglog/bug-tracker.ts b/src/buglog/bug-tracker.ts index fea41c43..5ff25734 100644 --- a/src/buglog/bug-tracker.ts +++ b/src/buglog/bug-tracker.ts @@ -5,7 +5,8 @@ interface BugEntry { id: string; timestamp: string; error_message: string; - file: string; + file?: string; + files?: string[]; line?: number; root_cause: string; fix: string; @@ -124,12 +125,19 @@ export function findSimilarBugs(wolfDir: string, errorMessage: string): ScoredBu export function searchBugs(wolfDir: string, term: string): BugEntry[] { const bugLog = readBugLog(wolfDir); const lower = term.toLowerCase(); + // Null-safe across schema drift: older entries omit `file`/`tags`, newer + // ones use a `files` array instead of a singular `file`. A missing field on + // any one entry must skip that entry, not throw and abort the whole search. + const has = (s: unknown): boolean => + typeof s === "string" && s.toLowerCase().includes(lower); + const hasAny = (a: unknown): boolean => Array.isArray(a) && a.some(has); return bugLog.bugs.filter( (b) => - b.error_message.toLowerCase().includes(lower) || - b.root_cause.toLowerCase().includes(lower) || - b.fix.toLowerCase().includes(lower) || - b.tags.some((t) => t.toLowerCase().includes(lower)) || - b.file.toLowerCase().includes(lower) + has(b.error_message) || + has(b.root_cause) || + has(b.fix) || + hasAny(b.tags) || + has(b.file) || + hasAny(b.files) ); } diff --git a/src/cli/codex-config.ts b/src/cli/codex-config.ts deleted file mode 100644 index cdaa11fb..00000000 --- a/src/cli/codex-config.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function getCodexConfigToml(): string { - return `[features] -hooks = true -`; -} diff --git a/src/cli/cron-cmd.ts b/src/cli/cron-cmd.ts index 5b2c710f..ce331b11 100644 --- a/src/cli/cron-cmd.ts +++ b/src/cli/cron-cmd.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { findProjectRoot } from "../scanner/project-root.js"; import { readJSON, writeJSON } from "../utils/fs-safe.js"; +import { getDashboardToken } from "../utils/dashboard-auth.js"; import { Logger } from "../utils/logger.js"; import { CronEngine } from "../daemon/cron-engine.js"; @@ -84,12 +85,13 @@ export async function cronRun(id: string): Promise { openwolf: { dashboard: { port: 18791 } }, }); const port = config.openwolf.dashboard.port; + const token = getDashboardToken(wolfDir); // Try calling the daemon's HTTP endpoint first try { const res = await fetch(`http://127.0.0.1:${port}/api/cron/run/${encodeURIComponent(id)}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, }); const body = await res.json() as { status?: string; error?: string }; if (res.ok) { diff --git a/src/cli/daemon-cmd.ts b/src/cli/daemon-cmd.ts index 74a9d634..aacd9000 100644 --- a/src/cli/daemon-cmd.ts +++ b/src/cli/daemon-cmd.ts @@ -1,4 +1,4 @@ -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import * as fs from "node:fs"; import * as net from "node:net"; import * as path from "node:path"; @@ -17,18 +17,22 @@ function getDashboardPort(): number { path.join(wolfDir, "config.json"), { openwolf: { dashboard: { port: 18791 } } } ); - return config.openwolf.dashboard.port; + const port = Number(config.openwolf.dashboard.port); + return Number.isInteger(port) && port > 0 && port <= 65535 ? port : 18791; } function getPm2Name(): string { const projectRoot = findProjectRoot(); - return `openwolf-${path.basename(projectRoot)}`; + return `openwolf-${path.basename(projectRoot).replace(/[^a-zA-Z0-9._-]/g, "-")}`; +} + +function pm2Bin(): string { + return isWindows() ? "pm2.cmd" : "pm2"; } function hasPm2(): boolean { try { - const cmd = isWindows() ? "where pm2" : "which pm2"; - execSync(cmd, { stdio: "ignore" }); + execFileSync(isWindows() ? "where" : "which", ["pm2"], { stdio: "ignore" }); return true; } catch { return false; @@ -38,7 +42,7 @@ function hasPm2(): boolean { function findPidOnPort(port: number): number | null { try { if (isWindows()) { - const output = execSync(`netstat -ano -p tcp`, { encoding: "utf-8" }); + const output = execFileSync("netstat", ["-ano", "-p", "tcp"], { encoding: "utf-8" }); for (const line of output.split("\n")) { if (line.includes(`:${port}`) && line.includes("LISTENING")) { const parts = line.trim().split(/\s+/); @@ -47,7 +51,7 @@ function findPidOnPort(port: number): number | null { } } } else { - const output = execSync(`lsof -ti :${port}`, { encoding: "utf-8" }); + const output = execFileSync("lsof", ["-ti", `:${port}`], { encoding: "utf-8" }); const pid = parseInt(output.trim(), 10); if (pid > 0) return pid; } @@ -58,7 +62,7 @@ function findPidOnPort(port: number): number | null { function killPid(pid: number): boolean { try { if (isWindows()) { - execSync(`taskkill /PID ${pid} /F`, { stdio: "ignore" }); + execFileSync("taskkill", ["/PID", String(pid), "/F"], { stdio: "ignore" }); } else { process.kill(pid, "SIGTERM"); } @@ -86,11 +90,11 @@ export function daemonStart(): void { const daemonScript = path.resolve(__dirname, "..", "daemon", "wolf-daemon.js"); try { - execSync(`pm2 start "${daemonScript}" --name ${name} --cwd "${projectRoot}" -- --env OPENWOLF_PROJECT_ROOT="${projectRoot}"`, { + execFileSync(pm2Bin(), ["start", daemonScript, "--name", name, "--cwd", projectRoot], { stdio: "inherit", env: { ...process.env, OPENWOLF_PROJECT_ROOT: projectRoot }, }); - execSync("pm2 save", { stdio: "ignore" }); + execFileSync(pm2Bin(), ["save"], { stdio: "ignore" }); console.log(`\n ✓ Daemon started: ${name}`); if (isWindows()) { console.log(" Tip: Run 'pm2-windows-startup' for boot persistence."); @@ -113,7 +117,7 @@ export function daemonStop(): void { if (hasPm2()) { const name = getPm2Name(); try { - execSync(`pm2 stop ${name}`, { stdio: "ignore" }); + execFileSync(pm2Bin(), ["stop", name], { stdio: "ignore" }); console.log(` ✓ Daemon stopped (PM2): ${name}`); return; } catch { @@ -148,7 +152,7 @@ export function daemonRestart(): void { if (hasPm2()) { const name = getPm2Name(); try { - execSync(`pm2 restart ${name}`, { stdio: "ignore" }); + execFileSync(pm2Bin(), ["restart", name], { stdio: "ignore" }); console.log(` ✓ Daemon restarted (PM2): ${name}`); return; } catch { @@ -182,7 +186,7 @@ export function daemonLogs(): void { const name = getPm2Name(); try { - execSync(`pm2 logs ${name} --lines 50 --nostream`, { stdio: "inherit" }); + execFileSync(pm2Bin(), ["logs", name, "--lines", "50", "--nostream"], { stdio: "inherit" }); } catch { console.error("Failed to get daemon logs."); } diff --git a/src/cli/dashboard.ts b/src/cli/dashboard.ts index 6354cbe4..c5bffe6d 100644 --- a/src/cli/dashboard.ts +++ b/src/cli/dashboard.ts @@ -5,13 +5,14 @@ import { fileURLToPath } from "node:url"; import { fork } from "node:child_process"; import { findProjectRoot } from "../scanner/project-root.js"; import { readJSON } from "../utils/fs-safe.js"; +import { getDashboardToken } from "../utils/dashboard-auth.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); interface WolfConfig { openwolf: { - dashboard: { port: number }; + dashboard: { port: number; host?: string }; }; } @@ -48,50 +49,84 @@ export async function dashboardCommand(): Promise { openwolf: { dashboard: { port: 18791 } }, }); - const port = config.openwolf.dashboard.port; - const url = `http://localhost:${port}`; - - // Check if daemon is already running on that port - const running = await isPortOpen(port); - - if (!running) { - console.log(" Daemon not running. Starting dashboard server..."); - - // Find the daemon script - const daemonScript = path.resolve(__dirname, "..", "daemon", "wolf-daemon.js"); - if (!fs.existsSync(daemonScript)) { - console.error(` Daemon script not found at: ${daemonScript}`); - console.log(" Run 'pnpm build' in the openwolf directory first."); - return; + const configuredPort = config.openwolf.dashboard.port; + const host = config.openwolf.dashboard.host || "127.0.0.1"; + const displayHost = host === "0.0.0.0" ? "localhost" : host; + const token = getDashboardToken(wolfDir); + + const daemonScript = path.resolve(__dirname, "..", "daemon", "wolf-daemon.js"); + + // Does the server on `p` accept THIS project's token? Distinguishes our own + // daemon from another project's daemon squatting the shared default port. + async function isOurDaemon(p: number): Promise { + try { + const res = await fetch(`http://${displayHost}:${p}/api/health`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(1500), + }); + return res.ok; + } catch { + return false; } + } - // Fork the daemon as a child process, passing project root explicitly + function spawnDaemon(p: number): void { const child = fork(daemonScript, [], { cwd: projectRoot, - env: { ...process.env, OPENWOLF_PROJECT_ROOT: projectRoot }, + env: { ...process.env, OPENWOLF_PROJECT_ROOT: projectRoot, OPENWOLF_DASHBOARD_PORT: String(p) }, + // Do not inherit the launcher's execArgv (e.g. --input-type=module): the + // daemon is a plain script and inherited flags can abort its startup. + execArgv: [], detached: true, stdio: "ignore", }); child.unref(); + } - // Wait for the port to open (up to 5 seconds) - let ready = false; + async function waitForOurDaemon(p: number): Promise { for (let i = 0; i < 25; i++) { await new Promise((r) => setTimeout(r, 200)); - if (await isPortOpen(port)) { - ready = true; - break; - } + if (await isOurDaemon(p)) return true; } + return false; + } + + if (!fs.existsSync(daemonScript)) { + console.error(` Daemon script not found at: ${daemonScript}`); + console.log(" Run 'pnpm build' in the openwolf directory first."); + return; + } - if (!ready) { + let servePort = configuredPort; + + if (await isPortOpen(configuredPort)) { + if (await isOurDaemon(configuredPort)) { + // Our own daemon is already serving this project. Reuse it. + } else { + // The port is held by another project's daemon (or an unrelated server). + // Start this project's dashboard on the next free port instead of + // opening a URL against a server that will reject our token with 401. + servePort = configuredPort + 1; + while (await isPortOpen(servePort) && servePort < configuredPort + 50) servePort++; + console.log(` Port ${configuredPort} is in use by another server. Starting this project's dashboard on port ${servePort}...`); + spawnDaemon(servePort); + if (!(await waitForOurDaemon(servePort))) { + console.log(` Server didn't start in time. Try: OPENWOLF_DASHBOARD_PORT=${servePort} node "${daemonScript}"`); + return; + } + console.log(` ✓ Dashboard server running on port ${servePort}`); + } + } else { + console.log(" Daemon not running. Starting dashboard server..."); + spawnDaemon(configuredPort); + if (!(await waitForOurDaemon(configuredPort))) { console.log(` Server didn't start in time. Try manually: node "${daemonScript}"`); return; } - - console.log(` ✓ Dashboard server running on port ${port}`); + console.log(` ✓ Dashboard server running on port ${configuredPort}`); } + const url = `http://${displayHost}:${servePort}/?token=${encodeURIComponent(token)}`; console.log(` Opening ${url}...`); try { diff --git a/src/cli/designqc-cmd.ts b/src/cli/designqc-cmd.ts deleted file mode 100644 index 8f81c6f5..00000000 --- a/src/cli/designqc-cmd.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as path from "node:path"; -import { findProjectRoot } from "../scanner/project-root.js"; -import { readJSON } from "../utils/fs-safe.js"; -import { DesignQCEngine } from "../designqc/designqc-engine.js"; -import type { DesignQCOptions, Viewport } from "../designqc/designqc-types.js"; -import { DEFAULT_VIEWPORTS } from "../designqc/designqc-types.js"; - -interface DesignQCCliOpts { - url?: string; - routes?: string[]; - quality?: string; - maxWidth?: string; - desktopOnly?: boolean; -} - -interface WolfConfig { - openwolf?: { - designqc?: { - viewports?: Viewport[]; - max_screenshots?: number; - chrome_path?: string | null; - }; - }; -} - -export async function designqcCommand( - target?: string, - opts?: DesignQCCliOpts, -): Promise { - const projectRoot = findProjectRoot(); - const wolfDir = path.join(projectRoot, ".wolf"); - const config = readJSON(path.join(wolfDir, "config.json"), {}); - const dc = config.openwolf?.designqc ?? {}; - - let viewports = dc.viewports || DEFAULT_VIEWPORTS; - if (opts?.desktopOnly) { - viewports = viewports.filter((v) => v.name === "desktop"); - if (viewports.length === 0) viewports = [DEFAULT_VIEWPORTS[0]]; - } - - const options: DesignQCOptions = { - targetFile: target, - devServerUrl: opts?.url, - routes: opts?.routes, - viewports, - maxScreenshots: dc.max_screenshots || 16, - chromePath: dc.chrome_path ?? undefined, - quality: Number(opts?.quality) || 70, - maxWidth: Number(opts?.maxWidth) || 1200, - }; - - console.log("\n OpenWolf Design QC — Screenshot Capture\n"); - - const engine = new DesignQCEngine(wolfDir, projectRoot, options); - await engine.capture(); -} diff --git a/src/cli/index.ts b/src/cli/index.ts index db70563a..210b0389 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -6,6 +6,7 @@ import { initCommand } from "./init.js"; import { statusCommand } from "./status.js"; import { scanCommand } from "./scan.js"; import { dashboardCommand } from "./dashboard.js"; +import { reportCommand } from "./report.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -29,11 +30,13 @@ export function createProgram(): Command { .version(getVersion()); program - .command("init [target]") - .description("Initialize .wolf/ and install Claude/Codex integration in current project") - .action(async (target?: string) => { - await initCommand(target); - }); + .command("init") + .description("Initialize .wolf/ in current project") + .option( + "--agent ", + "agents to wire up alongside Claude Code: codex, opencode, gemini, cursor, all. Default: auto-detect what's installed; pass 'claude' to wire Claude Code only" + ) + .action((opts: { agent?: string[] }) => initCommand(opts)); program .command("status") @@ -51,6 +54,11 @@ export function createProgram(): Command { .description("Open browser to dashboard") .action(dashboardCommand); + program + .command("report") + .description("Token report: estimated vs measured (from harness transcripts)") + .action(reportCommand); + const daemon = program .command("daemon") .description("Daemon management"); @@ -140,20 +148,6 @@ export function createProgram(): Command { restoreCommand(backup); }); - // --- Design QC command --- - program - .command("designqc [target]") - .description("Capture full-page screenshots for design evaluation by Claude Code") - .option("--url ", "Dev server URL (auto-starts server if omitted)") - .option("--routes ", "Specific routes to check") - .option("--quality ", "JPEG quality 1-100 (lower = fewer tokens)", "70") - .option("--max-width ", "Max capture width in px", "1200") - .option("--desktop-only", "Skip mobile viewport captures") - .action(async (target: string | undefined, opts: { url?: string; routes?: string[]; quality?: string; maxWidth?: string; desktopOnly?: boolean }) => { - const { designqcCommand } = await import("./designqc-cmd.js"); - await designqcCommand(target, opts); - }); - // --- Bug command --- const bug = program .command("bug") diff --git a/src/cli/init.test.ts b/src/cli/init.test.ts deleted file mode 100644 index f130b06c..00000000 --- a/src/cli/init.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { initCommand } from "./init.js"; - -const runInit = initCommand as unknown as (target?: string) => Promise; - -function makeProject(name: string): string { - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), `openwolf-${name}-`)); - fs.writeFileSync( - path.join(projectRoot, "package.json"), - JSON.stringify({ name, version: "1.0.0" }, null, 2), - "utf-8" - ); - fs.writeFileSync( - path.join(projectRoot, "index.ts"), - "export const value = 1;\n", - "utf-8" - ); - return projectRoot; -} - -function read(filePath: string): string { - return fs.readFileSync(filePath, "utf-8"); -} - -function readJSON(filePath: string): Record { - return JSON.parse(read(filePath)) as Record; -} - -test("default init installs both Claude and Codex integration files", async () => { - const projectRoot = makeProject("dual-target"); - const previousCwd = process.cwd(); - const previousHome = process.env.HOME; - const previousCodexVersion = process.env.OPENWOLF_CODEX_VERSION; - const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "openwolf-home-")); - - process.chdir(projectRoot); - process.env.HOME = fakeHome; - process.env.OPENWOLF_CODEX_VERSION = "0.129.0"; - - try { - await runInit(); - - assert.ok(fs.existsSync(path.join(projectRoot, ".wolf", "OPENWOLF.md"))); - assert.ok(fs.existsSync(path.join(projectRoot, "CLAUDE.md"))); - assert.ok(fs.existsSync(path.join(projectRoot, ".claude", "settings.json"))); - assert.ok(fs.existsSync(path.join(projectRoot, ".claude", "rules", "openwolf.md"))); - assert.ok(fs.existsSync(path.join(projectRoot, "AGENTS.md"))); - assert.ok(fs.existsSync(path.join(projectRoot, ".codex", "hooks.json"))); - assert.ok(fs.existsSync(path.join(projectRoot, ".codex", "config.toml"))); - assert.ok(fs.existsSync(path.join(projectRoot, ".wolf", "hooks", "claude", "session-start.js"))); - assert.ok(fs.existsSync(path.join(projectRoot, ".wolf", "hooks", "codex", "session-start.js"))); - assert.equal(fs.existsSync(path.join(projectRoot, ".wolf", "hooks", "session-start.js")), false); - - assert.match(read(path.join(projectRoot, "CLAUDE.md")), /@\.wolf\/OPENWOLF\.md/); - assert.match(read(path.join(projectRoot, "AGENTS.md")), /@\.wolf\/OPENWOLF\.md/); - assert.match( - JSON.stringify(readJSON(path.join(projectRoot, ".codex", "hooks.json"))), - /\.wolf\/hooks\/codex\/session-start\.js/ - ); - assert.match( - JSON.stringify(readJSON(path.join(projectRoot, ".codex", "hooks.json"))), - /git rev-parse --show-toplevel 2>\/dev\/null \|\| pwd/ - ); - assert.match(read(path.join(projectRoot, ".codex", "config.toml")), /hooks = true/); - assert.doesNotMatch(read(path.join(projectRoot, ".codex", "config.toml")), /codex_hooks = true/); - } finally { - process.chdir(previousCwd); - process.env.HOME = previousHome; - process.env.OPENWOLF_CODEX_VERSION = previousCodexVersion; - fs.rmSync(projectRoot, { recursive: true, force: true }); - fs.rmSync(fakeHome, { recursive: true, force: true }); - } -}); - -test("claude target only installs Claude integration files", async () => { - const projectRoot = makeProject("claude-only"); - const previousCwd = process.cwd(); - const previousHome = process.env.HOME; - const previousCodexVersion = process.env.OPENWOLF_CODEX_VERSION; - const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "openwolf-home-")); - - process.chdir(projectRoot); - process.env.HOME = fakeHome; - process.env.OPENWOLF_CODEX_VERSION = "0.129.0"; - - try { - await runInit("claude"); - - assert.ok(fs.existsSync(path.join(projectRoot, "CLAUDE.md"))); - assert.ok(fs.existsSync(path.join(projectRoot, ".claude", "settings.json"))); - assert.ok(fs.existsSync(path.join(projectRoot, ".wolf", "hooks", "claude", "session-start.js"))); - assert.equal(fs.existsSync(path.join(projectRoot, ".codex", "hooks.json")), false); - assert.equal(fs.existsSync(path.join(projectRoot, ".codex", "config.toml")), false); - assert.equal(fs.existsSync(path.join(projectRoot, ".wolf", "hooks", "codex", "session-start.js")), false); - assert.equal(fs.existsSync(path.join(projectRoot, "AGENTS.md")), false); - } finally { - process.chdir(previousCwd); - process.env.HOME = previousHome; - process.env.OPENWOLF_CODEX_VERSION = previousCodexVersion; - fs.rmSync(projectRoot, { recursive: true, force: true }); - fs.rmSync(fakeHome, { recursive: true, force: true }); - } -}); - -test("codex target only installs Codex integration files", async () => { - const projectRoot = makeProject("codex-only"); - const previousCwd = process.cwd(); - const previousHome = process.env.HOME; - const previousCodexVersion = process.env.OPENWOLF_CODEX_VERSION; - const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "openwolf-home-")); - - process.chdir(projectRoot); - process.env.HOME = fakeHome; - process.env.OPENWOLF_CODEX_VERSION = "0.129.0"; - - try { - await runInit("codex"); - - assert.ok(fs.existsSync(path.join(projectRoot, "AGENTS.md"))); - assert.ok(fs.existsSync(path.join(projectRoot, ".codex", "hooks.json"))); - assert.ok(fs.existsSync(path.join(projectRoot, ".codex", "config.toml"))); - assert.ok(fs.existsSync(path.join(projectRoot, ".wolf", "hooks", "codex", "session-start.js"))); - assert.equal(fs.existsSync(path.join(projectRoot, ".wolf", "hooks", "claude", "session-start.js")), false); - assert.equal(fs.existsSync(path.join(projectRoot, "CLAUDE.md")), false); - assert.equal(fs.existsSync(path.join(projectRoot, ".claude", "settings.json")), false); - assert.match(read(path.join(projectRoot, ".codex", "config.toml")), /hooks = true/); - assert.doesNotMatch(read(path.join(projectRoot, ".codex", "config.toml")), /codex_hooks = true/); - } finally { - process.chdir(previousCwd); - process.env.HOME = previousHome; - process.env.OPENWOLF_CODEX_VERSION = previousCodexVersion; - fs.rmSync(projectRoot, { recursive: true, force: true }); - fs.rmSync(fakeHome, { recursive: true, force: true }); - } -}); diff --git a/src/cli/init.ts b/src/cli/init.ts index 547e7fc1..8d862e6c 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -1,14 +1,16 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { findProjectRoot } from "../scanner/project-root.js"; import { scanProject } from "../scanner/anatomy-scanner.js"; -import { readJSON, writeJSON, readText, writeText } from "../utils/fs-safe.js"; +import { readJSON, writeJSON, readText, writeText, safeCopyFile } from "../utils/fs-safe.js"; import { ensureDir } from "../utils/paths.js"; import { isWindows } from "../utils/platform.js"; -import { registerProject } from "./registry.js"; -import { getCodexConfigToml } from "./codex-config.js"; +import { registerProject, getRegisteredProjects } from "./registry.js"; +import { resolveAgents, detectInstalledAgents } from "../agents/index.js"; +import { installSkills } from "../agents/skills.js"; +import { newStore, importFromMarkdown, saveStore, loadStore, STORE_FILE, sha256 as storeSha256 } from "../hooks/anatomy-store.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -24,10 +26,14 @@ function getVersion(): string { } } -// Files that are safe to overwrite on upgrade (config/protocol, not user data) +// Files that are safe to overwrite on upgrade (protocol docs only, not user data). +// NOTE: config.json is deliberately NOT here. It holds per-project port +// assignments (openwolf.daemon.port / openwolf.dashboard.port) and other +// user tunables; overwriting it on re-init resets every project to the same +// default ports (18790 / 18791), so only the first daemon to start can bind +// and the rest crash-loop on EADDRINUSE. It is handled by reconcileConfig(). const ALWAYS_OVERWRITE = [ "OPENWOLF.md", - "config.json", "reframe-frameworks.md", ]; @@ -37,19 +43,16 @@ const CREATE_IF_MISSING = [ "cerebrum.md", "memory.md", "anatomy.md", + "STATUS.md", "token-ledger.json", "buglog.json", "cron-manifest.json", "cron-state.json", - "designqc-report.json", "suggestions.json", ]; -type InitTarget = "claude" | "codex"; - -// Use ${CLAUDE_PROJECT_DIR} with exec form so hooks resolve correctly regardless of CWD. -// The second arg passes the project dir to the hook scripts, making them independent of process.cwd(). -const CLAUDE_HOOK_SETTINGS = { +// Use $CLAUDE_PROJECT_DIR so hooks resolve correctly even if CWD changes during a session +const HOOK_SETTINGS = { hooks: { SessionStart: [ { @@ -57,8 +60,7 @@ const CLAUDE_HOOK_SETTINGS = { hooks: [ { type: "command", - command: "node", - args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/session-start.js", "${CLAUDE_PROJECT_DIR}"], + command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/session-start.js"', timeout: 5, }, ], @@ -70,8 +72,7 @@ const CLAUDE_HOOK_SETTINGS = { hooks: [ { type: "command", - command: "node", - args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/pre-read.js", "${CLAUDE_PROJECT_DIR}"], + command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-read.js"', timeout: 5, }, ], @@ -81,8 +82,7 @@ const CLAUDE_HOOK_SETTINGS = { hooks: [ { type: "command", - command: "node", - args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/pre-write.js", "${CLAUDE_PROJECT_DIR}"], + command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-write.js"', timeout: 5, }, ], @@ -94,8 +94,7 @@ const CLAUDE_HOOK_SETTINGS = { hooks: [ { type: "command", - command: "node", - args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/post-read.js", "${CLAUDE_PROJECT_DIR}"], + command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-read.js"', timeout: 5, }, ], @@ -105,102 +104,32 @@ const CLAUDE_HOOK_SETTINGS = { hooks: [ { type: "command", - command: "node", - args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/post-write.js", "${CLAUDE_PROJECT_DIR}"], + command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js"', timeout: 10, }, ], }, ], - Stop: [ + PreCompact: [ { matcher: "", hooks: [ { type: "command", - command: "node", - args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/stop.js", "${CLAUDE_PROJECT_DIR}"], - timeout: 10, - }, - ], - }, - ], - }, -}; - -const CODEX_PROJECT_ROOT = '$(git rev-parse --show-toplevel 2>/dev/null || pwd)'; - -const CODEX_HOOK_SETTINGS = { - hooks: { - SessionStart: [ - { - matcher: "startup|resume|clear", - hooks: [ - { - type: "command", - command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/session-start.js"`, - timeout: 5, - statusMessage: "OpenWolf session bootstrap", - }, - ], - }, - ], - PreToolUse: [ - { - matcher: "Read", - hooks: [ - { - type: "command", - command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/pre-read.js"`, - timeout: 5, - statusMessage: "OpenWolf read precheck", - }, - ], - }, - { - matcher: "Edit|Write|MultiEdit|apply_patch", - hooks: [ - { - type: "command", - command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/pre-write.js"`, - timeout: 5, - statusMessage: "OpenWolf write precheck", - }, - ], - }, - ], - PostToolUse: [ - { - matcher: "Read", - hooks: [ - { - type: "command", - command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/post-read.js"`, + command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/precompact.js"', timeout: 5, - statusMessage: "OpenWolf read tracking", - }, - ], - }, - { - matcher: "Edit|Write|MultiEdit|apply_patch", - hooks: [ - { - type: "command", - command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/post-write.js"`, - timeout: 10, - statusMessage: "OpenWolf write tracking", }, ], }, ], Stop: [ { + matcher: "", hooks: [ { type: "command", - command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/stop.js"`, + command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/stop.js"', timeout: 10, - statusMessage: "OpenWolf session finalize", }, ], }, @@ -208,7 +137,7 @@ const CODEX_HOOK_SETTINGS = { }, }; -export async function initCommand(targetArg?: string): Promise { +export async function initCommand(options?: { agent?: string[] }): Promise { // Check Node.js version const nodeVersion = parseInt(process.version.slice(1), 10); if (nodeVersion < 20) { @@ -216,8 +145,6 @@ export async function initCommand(targetArg?: string): Promise { process.exit(1); } - const targets = resolveInitTargets(targetArg); - // Detect project root const projectRoot = findProjectRoot(); console.log(`Project root: ${projectRoot}`); @@ -247,6 +174,7 @@ export async function initCommand(targetArg?: string): Promise { createdCount++; } + const newlyCreated = new Set(); for (const file of CREATE_IF_MISSING) { const destPath = path.join(wolfDir, file); if (fs.existsSync(destPath)) { @@ -254,15 +182,30 @@ export async function initCommand(targetArg?: string): Promise { } else { writeTemplateFile(actualTemplatesDir, wolfDir, file); createdCount++; + newlyCreated.add(file); } } + // config.json: create-if-missing, and on a fresh create allocate a port + // pair that no other registered project is using. Existing configs keep + // their ports untouched so a re-init never resets them. + if (reconcileConfig(actualTemplatesDir, wolfDir, projectRoot)) { + createdCount++; + } else { + skippedCount++; + } + // --- Cerebrum: seed project info only if fresh --- if (!isUpgrade) { seedCerebrum(wolfDir, projectRoot); seedIdentity(wolfDir, projectRoot); } + // --- STATUS.md: substitute {{PROJECT_NAME}} / {{DATE}} when freshly created --- + if (newlyCreated.has("STATUS.md")) { + seedStatus(wolfDir, projectRoot); + } + // --- Token ledger: set created_at only if empty --- const ledgerPath = path.join(wolfDir, "token-ledger.json"); const ledger = readJSON>(ledgerPath, {}); @@ -272,48 +215,68 @@ export async function initCommand(targetArg?: string): Promise { } // --- Hook scripts: always update (bug fixes, new features) --- - copyHookScripts(wolfDir, targets); + copyHookScripts(wolfDir); - if (targets.includes("claude")) { - installClaudeIntegration(projectRoot, actualTemplatesDir); - } - if (targets.includes("codex")) { - installCodexIntegration(projectRoot, actualTemplatesDir); + // --- Claude settings: replace OpenWolf hooks (upgrade old paths) --- + const claudeDir = path.join(projectRoot, ".claude"); + ensureDir(claudeDir); + + const settingsPath = path.join(claudeDir, "settings.json"); + if (fs.existsSync(settingsPath)) { + const existing = readJSON>(settingsPath, {}); + const merged = replaceOpenWolfHooks(existing, HOOK_SETTINGS); + writeJSON(settingsPath, merged); + } else { + writeJSON(settingsPath, HOOK_SETTINGS); } - // --- Anatomy scan: only on fresh init --- - let fileCount = 0; - if (!isUpgrade) { - try { - fileCount = scanProject(wolfDir, projectRoot); - } catch { - console.log(" Anatomy scan deferred — will run on first session."); + // --- Claude rules: always update --- + const rulesDir = path.join(claudeDir, "rules"); + ensureDir(rulesDir); + const rulesContent = readTemplateContent("claude-rules-openwolf.md", actualTemplatesDir); + writeText(path.join(rulesDir, "openwolf.md"), rulesContent); + + // --- CLAUDE.md: add snippet if missing --- + const claudeMdPath = path.join(projectRoot, "CLAUDE.md"); + const snippetContent = readTemplateContent("claude-md-snippet.md", actualTemplatesDir); + if (fs.existsSync(claudeMdPath)) { + const existing = readText(claudeMdPath); + if (!existing.includes("OpenWolf")) { + writeText(claudeMdPath, snippetContent + "\n\n" + existing); } } else { - // On upgrade, read existing count + writeText(claudeMdPath, snippetContent); + } + + // --- One-time anatomy store migration for upgrades (F2b) --- + if (isUpgrade) { try { - const anatomyContent = readText(path.join(wolfDir, "anatomy.md")); - const m = anatomyContent.match(/Files:\s*(\d+)/); - fileCount = m ? parseInt(m[1], 10) : 0; - } catch { - fileCount = 0; - } + if (!fs.existsSync(path.join(wolfDir, STORE_FILE))) { + const md = readText(path.join(wolfDir, "anatomy.md")); + if (md) { + const store = newStore(); + importFromMarkdown(store, md, projectRoot); + store.meta.renderedHash = storeSha256(md); + saveStore(wolfDir, store); + console.log(` ✓ anatomy-index.json created (migrated from anatomy.md)`); + } + } + } catch {} } // --- Daemon --- let daemonStatus = "start manually with: openwolf daemon start"; try { - const pm2Cmd = isWindows() ? "where pm2" : "which pm2"; - execSync(pm2Cmd, { stdio: "ignore" }); - const name = `openwolf-${path.basename(projectRoot)}`; + execFileSync(isWindows() ? "where" : "which", ["pm2"], { stdio: "ignore" }); + const name = `openwolf-${path.basename(projectRoot).replace(/[^a-zA-Z0-9._-]/g, "-")}`; // Resolve daemon script relative to openwolf's install dir, not the target project const daemonScript = path.resolve(__dirname, "..", "daemon", "wolf-daemon.js"); try { - execSync(`pm2 start "${daemonScript}" --name ${name} --cwd "${projectRoot}"`, { + execFileSync(isWindows() ? "pm2.cmd" : "pm2", ["start", daemonScript, "--name", name, "--cwd", projectRoot], { stdio: "ignore", env: { ...process.env, OPENWOLF_PROJECT_ROOT: projectRoot }, }); - execSync("pm2 save", { stdio: "ignore" }); + execFileSync(isWindows() ? "pm2.cmd" : "pm2", ["save"], { stdio: "ignore" }); daemonStatus = "running via pm2"; } catch { daemonStatus = "pm2 found but daemon start failed. Try: openwolf daemon start"; @@ -334,37 +297,83 @@ export async function initCommand(targetArg?: string): Promise { // Non-fatal — registry is a convenience feature } + // --- Additional agents (Workstream C): codex / opencode / gemini / cursor --- + // No --agent flag → auto-detect what's installed on this machine and wire + // only that. `--agent claude` opts out; explicit names override detection. + let agentNames = options?.agent ?? []; + let autoDetected = false; + if (agentNames.length === 0) { + agentNames = detectInstalledAgents(); + autoDetected = agentNames.length > 0; + } + const installedAgents: string[] = ["claude"]; + if (agentNames.length > 0) { + if (autoDetected) { + console.log(` ✓ Agents detected on this machine: ${agentNames.join(", ")} (auto-wiring; use --agent claude to skip)`); + } + const adapters = resolveAgents(agentNames); // throws on unknown names + const ctx = { projectRoot, wolfDir, templatesDir: actualTemplatesDir }; + for (const adapter of adapters) { + const result = adapter.install(ctx); + installedAgents.push(adapter.name); + for (const line of result.actions) console.log(` ✓ ${line}`); + for (const warn of result.warnings) console.log(` ⚠ ${adapter.displayName}: ${warn}`); + } + } + // Record which agents are wired up so `openwolf update`/dashboard know. + try { + const cfgPath = path.join(wolfDir, "config.json"); + const cfg = readJSON(cfgPath, null as any); + if (cfg && cfg.openwolf) { + cfg.openwolf.agents = installedAgents; + writeJSON(cfgPath, cfg); + } + } catch {} + + // --- Bundled skills (Workstream H): /security-audit, /reframe --- + try { + for (const line of installSkills(projectRoot, actualTemplatesDir, installedAgents)) { + console.log(` ✓ ${line}`); + } + } catch {} + + // --- Anatomy scan: runs LAST so the index reflects everything init created --- + let fileCount = 0; + if (!isUpgrade) { + try { + fileCount = scanProject(wolfDir, projectRoot); + } catch { + console.log(" Anatomy scan deferred — will run on first session."); + } + } else { + const store = loadStore(wolfDir); + if (store) { + fileCount = Object.keys(store.files).length; + } else { + const m = readText(path.join(wolfDir, "anatomy.md")).match(/Files:\s*(\d+)/); + if (m) fileCount = parseInt(m[1], 10); + } + } + // --- Summary --- console.log(""); if (isUpgrade) { console.log(` ✓ OpenWolf upgraded to v${version}`); console.log(` ✓ All .wolf data preserved (${skippedCount} files: cerebrum, memory, anatomy, buglog, ledger)`); - console.log(` ✓ Hook scripts updated (6 hooks)`); + console.log(` ✓ Hook scripts updated (7 hooks)`); console.log(` ✓ ${createdCount} config files updated`); - if (targets.includes("claude")) { - console.log(` ✓ Claude integration refreshed`); - } - if (targets.includes("codex")) { - console.log(` ✓ Codex integration refreshed`); - } console.log(` ✓ Anatomy: ${fileCount} files tracked (unchanged)`); } else { console.log(` ✓ OpenWolf v${version} initialized`); console.log(` ✓ .wolf/ created with ${createdCount} files`); - if (targets.includes("claude")) { - console.log(` ✓ Claude Code hooks registered (6 hooks)`); - console.log(` ✓ CLAUDE.md updated`); - console.log(` ✓ .claude/rules/openwolf.md created`); - } - if (targets.includes("codex")) { - console.log(` ✓ Codex hooks registered (.codex/hooks.json)`); - console.log(` ✓ AGENTS.md updated for Codex`); - } + console.log(` ✓ Claude Code hooks registered (7 hooks)`); + console.log(` ✓ CLAUDE.md updated`); + console.log(` ✓ .claude/rules/openwolf.md created`); console.log(` ✓ Anatomy scan: ${fileCount} files indexed`); } console.log(` ✓ Daemon: ${daemonStatus}`); console.log(""); - console.log(` You're ready. Just use '${formatTargetsForHumans(targets)}' as normal — OpenWolf is watching.`); + console.log(" You're ready. Just use 'claude' as normal — OpenWolf is watching."); console.log(""); } @@ -383,60 +392,68 @@ function findTemplatesDir(): string { return candidates[0]; // fallback — generateTemplate will handle missing files } -function resolveInitTargets(targetArg?: string): InitTarget[] { - if (!targetArg) return ["claude", "codex"]; - if (targetArg === "claude" || targetArg === "codex") { - return [targetArg]; - } - - console.error(`Unknown init target: ${targetArg}`); - console.error("Valid targets: claude, codex"); - process.exit(1); -} - -function installClaudeIntegration(projectRoot: string, templatesDir: string): void { - const claudeDir = path.join(projectRoot, ".claude"); - ensureDir(claudeDir); - - const settingsPath = path.join(claudeDir, "settings.json"); - if (fs.existsSync(settingsPath)) { - const existing = readJSON>(settingsPath, {}); - const merged = replaceOpenWolfHooks(existing, CLAUDE_HOOK_SETTINGS); - writeJSON(settingsPath, merged); +function writeTemplateFile(templatesDir: string, wolfDir: string, file: string): void { + const srcPath = path.join(templatesDir, file); + const destPath = path.join(wolfDir, file); + if (fs.existsSync(srcPath)) { + safeCopyFile(srcPath, destPath); } else { - writeJSON(settingsPath, CLAUDE_HOOK_SETTINGS); + generateTemplate(destPath, file); } - - const rulesDir = path.join(claudeDir, "rules"); - ensureDir(rulesDir); - const rulesContent = readTemplateContent("claude-rules-openwolf.md", templatesDir); - writeText(path.join(rulesDir, "openwolf.md"), rulesContent); - - const claudeMdPath = path.join(projectRoot, "CLAUDE.md"); - const snippetContent = readTemplateContent("claude-md-snippet.md", templatesDir); - prependSnippetIfMissing(claudeMdPath, snippetContent); } -function installCodexIntegration(projectRoot: string, templatesDir: string): void { - const codexDir = path.join(projectRoot, ".codex"); - ensureDir(codexDir); +// Default daemon/dashboard ports. A fresh project is allocated the next free +// pair so multiple projects' daemons never collide on the same port. +const DEFAULT_DAEMON_PORT = 18790; +const DEFAULT_DASHBOARD_PORT = 18791; - writeJSON(path.join(codexDir, "hooks.json"), CODEX_HOOK_SETTINGS); - writeText(path.join(codexDir, "config.toml"), getCodexConfigToml()); +function normalizeRoot(p: string): string { + const resolved = path.resolve(p); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} - const agentsPath = path.join(projectRoot, "AGENTS.md"); - const snippetContent = readTemplateContent("agents-md-snippet.md", templatesDir); - prependSnippetIfMissing(agentsPath, snippetContent); +// Ports already claimed by OTHER registered projects' config.json files. +function collectUsedPorts(excludeRoot: string): Set { + const used = new Set(); + const exclude = normalizeRoot(excludeRoot); + for (const proj of getRegisteredProjects(false)) { + if (normalizeRoot(proj.root) === exclude) continue; + const cfg = readJSON(path.join(proj.root, ".wolf", "config.json"), null as any); + const ow = cfg && cfg.openwolf; + if (ow) { + if (ow.daemon && typeof ow.daemon.port === "number") used.add(ow.daemon.port); + if (ow.dashboard && typeof ow.dashboard.port === "number") used.add(ow.dashboard.port); + } + } + return used; } -function writeTemplateFile(templatesDir: string, wolfDir: string, file: string): void { - const srcPath = path.join(templatesDir, file); - const destPath = path.join(wolfDir, file); - if (fs.existsSync(srcPath)) { - fs.copyFileSync(srcPath, destPath); - } else { - generateTemplate(destPath, file); +// config.json is user data: created from template only when absent, and on +// that fresh create it is stamped with a port pair no other registered +// project uses. An existing config is left completely untouched so a re-init +// never resets its ports. Returns true if a new file was written. +function reconcileConfig(templatesDir: string, wolfDir: string, projectRoot: string): boolean { + const cfgPath = path.join(wolfDir, "config.json"); + if (fs.existsSync(cfgPath)) { + return false; // preserve existing ports + user tunables + } + writeTemplateFile(templatesDir, wolfDir, "config.json"); + const cfg = readJSON(cfgPath, null as any); + if (cfg && cfg.openwolf && cfg.openwolf.dashboard) { + const used = collectUsedPorts(projectRoot); + const nextFree = (base: number): number => { + let p = base; + while (used.has(p)) p++; + used.add(p); + return p; + }; + cfg.openwolf.daemon = cfg.openwolf.daemon || {}; + cfg.openwolf.dashboard = cfg.openwolf.dashboard || {}; + cfg.openwolf.daemon.port = nextFree(DEFAULT_DAEMON_PORT); + cfg.openwolf.dashboard.port = nextFree(DEFAULT_DASHBOARD_PORT); + writeJSON(cfgPath, cfg); } + return true; } function readTemplateContent(filename: string, templatesDir: string): string { @@ -450,29 +467,11 @@ function readTemplateContent(filename: string, templatesDir: string): string { function getEmbeddedTemplate(filename: string): string { const templates: Record = { "claude-md-snippet.md": `# OpenWolf\n\n@.wolf/OPENWOLF.md\n\nThis project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md every session. Check .wolf/cerebrum.md before generating code. Check .wolf/anatomy.md before reading files.`, - "agents-md-snippet.md": `# OpenWolf\n\n@.wolf/OPENWOLF.md\n\nThis project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md every session. Check .wolf/cerebrum.md before generating code. Check .wolf/anatomy.md before reading files.`, - "claude-rules-openwolf.md": `---\ndescription: OpenWolf protocol enforcement — active on all files\nglobs: **/*\n---\n\n- Check .wolf/anatomy.md before reading any project file\n- Check .wolf/cerebrum.md Do-Not-Repeat list before generating code\n- After writing or editing files, update .wolf/anatomy.md and append to .wolf/memory.md\n- After receiving a user correction, update .wolf/cerebrum.md immediately (Preferences, Learnings, or Do-Not-Repeat)\n- LEARN from every interaction: if you discover a convention, user preference, or project pattern, add it to .wolf/cerebrum.md. Low threshold — when in doubt, log it.\n- BEFORE fixing any bug or error: read .wolf/buglog.json for known fixes\n- AFTER fixing any bug, error, failed test, failed build, or user-reported problem: ALWAYS log to .wolf/buglog.json with error_message, root_cause, fix, and tags\n- If you edit a file more than twice in a session, that likely indicates a bug — log it to .wolf/buglog.json\n- When the user asks to check/evaluate UI design: run \`openwolf designqc\` to capture screenshots, then read them from .wolf/designqc-captures/\n- When the user asks to change/pick/migrate UI framework: read .wolf/reframe-frameworks.md, ask decision questions, recommend a framework, then execute with the framework's prompt`, + "claude-rules-openwolf.md": `---\ndescription: OpenWolf protocol enforcement — active on all files\nglobs: **/*\n---\n\n- Read .wolf/STATUS.md FIRST when resuming a session — it contains current quest, next steps, decisions\n- Update .wolf/STATUS.md (✅ done / 🚀 next quest) when a quest finishes or before suggesting /clear\n- Check .wolf/anatomy.md before reading any project file\n- Check .wolf/cerebrum.md Do-Not-Repeat list before generating code\n- After writing or editing files, update .wolf/anatomy.md and append to .wolf/memory.md\n- After receiving a user correction, update .wolf/cerebrum.md immediately (Preferences, Learnings, or Do-Not-Repeat)\n- LEARN from every interaction: if you discover a convention, user preference, or project pattern, add it to .wolf/cerebrum.md. Low threshold — when in doubt, log it.\n- BEFORE fixing any bug or error: read .wolf/buglog.json for known fixes\n- AFTER fixing any bug, error, failed test, failed build, or user-reported problem: ALWAYS log to .wolf/buglog.json with error_message, root_cause, fix, and tags\n- If you edit a file more than twice in a session, that likely indicates a bug — log it to .wolf/buglog.json\n- When the user asks to change/pick/migrate UI framework: read .wolf/reframe-frameworks.md, ask decision questions, recommend a framework, then execute with the framework's prompt`, }; return templates[filename] ?? ""; } -function prependSnippetIfMissing(filePath: string, snippetContent: string): void { - if (fs.existsSync(filePath)) { - const existing = readText(filePath); - if (!existing.includes("OpenWolf")) { - writeText(filePath, snippetContent + "\n\n" + existing); - } - return; - } - - writeText(filePath, snippetContent); -} - -function formatTargetsForHumans(targets: InitTarget[]): string { - if (targets.length === 2) return "claude' or 'codex"; - return targets[0]; -} - function generateTemplate(destPath: string, file: string): void { const templates: Record = { "OPENWOLF.md": `# OpenWolf Operating Protocol\n\nYou are working in an OpenWolf-managed project. These rules apply every turn.\n\n## File Navigation\n\n1. Check \`.wolf/anatomy.md\` BEFORE reading any file.\n2. If the description is sufficient, do NOT read the full file.\n3. If a file is not in anatomy.md, search with Grep/Glob.\n\n## Code Generation\n\n1. Read \`.wolf/cerebrum.md\` and respect every entry.\n2. Check \`## Do-Not-Repeat\` section.\n\n## After Actions\n\n1. Append to \`.wolf/memory.md\`.\n2. After file changes: update \`.wolf/anatomy.md\`.\n\n## Token Discipline\n\n- Never re-read a file already read this session.\n- Prefer anatomy.md descriptions over full reads.\n`, @@ -480,6 +479,7 @@ function generateTemplate(destPath: string, file: string): void { "cerebrum.md": `# Cerebrum\n\n> OpenWolf's learning memory.\n\n## User Preferences\n\n## Key Learnings\n\n## Do-Not-Repeat\n\n## Decision Log\n`, "memory.md": `# Memory\n\n> Chronological action log.\n`, "anatomy.md": `# anatomy.md\n\n> Project structure index. Pending initial scan.\n`, + "STATUS.md": `# STATUS\n\n> Single source of truth for resuming work. Read this FIRST when starting a session.\n> Update at the end of every work phase so the next \`/clear\` resumes in 1 read.\n\n---\n\n## ✅ Done\n\n- (nothing yet — fill in as work completes)\n\n---\n\n## 🚀 Next phase\n\n**Goal:** __\n\n### Acceptance criteria\n1. __\n\n### Files to create / edit\n- __\n\n### Open decisions\n- __\n\n---\n\n## 📁 Active architecture\n\n- **Stack:** __\n\n---\n\n## 🔧 Useful commands\n\n\`\`\`bash\n# add the most-used commands here\n\`\`\`\n`, "config.json": JSON.stringify({ version: 1, openwolf: { @@ -489,16 +489,16 @@ function generateTemplate(destPath: string, file: string): void { cron: { enabled: true, max_retry_attempts: 3, dead_letter_enabled: true, heartbeat_interval_minutes: 30, use_claude_p: true, api_key_env: null }, memory: { consolidation_after_days: 7, max_entries_before_consolidation: 200 }, cerebrum: { max_tokens: 2000, reflection_frequency: "weekly" }, + context: { session_digest_budget_tokens: 1500, budgets: { claude: 1500, codex: 1200, gemini: 1200, opencode: 1200, cursor: 800 } }, daemon: { port: 18790, log_level: "info" }, - dashboard: { enabled: true, port: 18791 }, - designqc: { enabled: true, viewports: [{ name: "desktop", width: 1440, height: 900 }, { name: "mobile", width: 375, height: 812 }], max_screenshots: 6, chrome_path: null }, + dashboard: { enabled: true, port: 18791, host: "127.0.0.1" }, + buglog: { auto_detect: true }, }, }, null, 2), "token-ledger.json": JSON.stringify({ version: 1, created_at: "", lifetime: { total_tokens_estimated: 0, total_reads: 0, total_writes: 0, total_sessions: 0, anatomy_hits: 0, anatomy_misses: 0, repeated_reads_blocked: 0, estimated_savings_vs_bare_cli: 0 }, sessions: [], daemon_usage: [], waste_flags: [], optimization_report: { last_generated: null, patterns: [] } }, null, 2), "buglog.json": JSON.stringify({ version: 1, bugs: [] }, null, 2), "cron-manifest.json": JSON.stringify({ version: 1, tasks: [] }, null, 2), "cron-state.json": JSON.stringify({ last_heartbeat: null, engine_status: "initialized", execution_log: [], dead_letter_queue: [], upcoming: [] }, null, 2), - "designqc-report.json": JSON.stringify({ captured_at: null, captures: [], total_size_kb: 0, estimated_tokens: 0 }, null, 2), "suggestions.json": JSON.stringify({ suggestions: [], generated_at: null }, null, 2), }; @@ -534,6 +534,19 @@ function seedCerebrum(wolfDir: string, projectRoot: string): void { writeText(cerebrumPath, cerebrum); } +function seedStatus(wolfDir: string, projectRoot: string): void { + const statusPath = path.join(wolfDir, "STATUS.md"); + if (!fs.existsSync(statusPath)) return; + + const projectName = detectProjectName(projectRoot) || path.basename(projectRoot); + const date = new Date().toISOString().slice(0, 10); + + let content = readText(statusPath); + content = content.replace(/\{\{PROJECT_NAME\}\}/g, projectName); + content = content.replace(/\{\{DATE\}\}/g, date); + writeText(statusPath, content); +} + function seedIdentity(wolfDir: string, projectRoot: string): void { const projectName = detectProjectName(projectRoot); if (!projectName) return; @@ -548,7 +561,7 @@ function seedIdentity(wolfDir: string, projectRoot: string): void { writeText(identityPath, content); } -function copyHookScripts(wolfDir: string, targets: InitTarget[]): void { +function copyHookScripts(wolfDir: string): void { const hooksDir = path.join(wolfDir, "hooks"); ensureDir(hooksDir); @@ -575,39 +588,32 @@ function copyHookScripts(wolfDir: string, targets: InitTarget[]): void { "pre-write.js", "post-read.js", "post-write.js", + "precompact.js", "stop.js", "shared.js", + "anatomy-store.js", + "anatomy-lock.js", ]; let copiedAny = false; if (sourceDir) { - removeLegacyTopLevelHooks(hooksDir, hookFiles); - for (const target of targets) { - const providerDir = path.join(hooksDir, target); - ensureDir(providerDir); - for (const file of hookFiles) { - const src = path.join(sourceDir, file); - if (fs.existsSync(src)) { - fs.copyFileSync(src, path.join(providerDir, file)); - copiedAny = true; - } + for (const file of hookFiles) { + const src = path.join(sourceDir, file); + if (fs.existsSync(src)) { + safeCopyFile(src, path.join(hooksDir, file)); + copiedAny = true; } } } else if (fs.existsSync(srcHooksDir)) { // Dev mode: compile TS hooks inline using a simple copy with note // In practice, user should run `pnpm build:hooks` first - removeLegacyTopLevelHooks(hooksDir, hookFiles); - for (const target of targets) { - const providerDir = path.join(hooksDir, target); - ensureDir(providerDir); - for (const file of hookFiles) { - const tsFile = file.replace(".js", ".ts"); - const src = path.join(srcHooksDir, tsFile); - if (fs.existsSync(src)) { - const loaderContent = `#!/usr/bin/env node\n// Auto-generated by openwolf init — run 'pnpm build:hooks' for compiled version\nimport("${src.replace(/\\/g, "/")}");\n`; - fs.writeFileSync(path.join(providerDir, file), loaderContent, "utf-8"); - copiedAny = true; - } + for (const file of hookFiles) { + const tsFile = file.replace(".js", ".ts"); + const src = path.join(srcHooksDir, tsFile); + if (fs.existsSync(src)) { + const loaderContent = `#!/usr/bin/env node\n// Auto-generated by openwolf init — run 'pnpm build:hooks' for compiled version\nimport("${src.replace(/\\/g, "/")}");\n`; + fs.writeFileSync(path.join(hooksDir, file), loaderContent, "utf-8"); + copiedAny = true; } } } @@ -621,17 +627,6 @@ function copyHookScripts(wolfDir: string, targets: InitTarget[]): void { fs.writeFileSync(hooksPkgPath, JSON.stringify({ type: "module" }, null, 2) + "\n", "utf-8"); } -function removeLegacyTopLevelHooks(hooksDir: string, hookFiles: string[]): void { - for (const file of hookFiles) { - const legacyPath = path.join(hooksDir, file); - if (fs.existsSync(legacyPath)) { - try { - fs.unlinkSync(legacyPath); - } catch {} - } - } -} - /** * Replace all OpenWolf hook entries in settings.json with the current version. * Removes old-style relative-path hooks and inserts the new $CLAUDE_PROJECT_DIR hooks. @@ -639,24 +634,23 @@ function removeLegacyTopLevelHooks(hooksDir: string, hookFiles: string[]): void */ function replaceOpenWolfHooks( existing: Record, - hookSettings: typeof CLAUDE_HOOK_SETTINGS + hookSettings: typeof HOOK_SETTINGS ): Record { const merged = { ...existing }; if (!merged.hooks) { merged.hooks = {}; } - const hooks = merged.hooks as Record }>>; + const hooks = merged.hooks as Record }>>; for (const [event, newMatchers] of Object.entries(hookSettings.hooks)) { if (!hooks[event]) { hooks[event] = []; } - // Remove any existing OpenWolf hook entries (match by .wolf/hooks/ in command or args) + // Remove any existing OpenWolf hook entries (match by .wolf/hooks/ in command) hooks[event] = hooks[event].filter((entry) => { const isOpenWolfHook = entry.hooks?.some( - (h) => (h.command && h.command.includes(".wolf/hooks/")) || - (h.args && h.args.some((a: string) => a.includes(".wolf/hooks/"))) + (h) => h.command && h.command.includes(".wolf/hooks/") ); return !isOpenWolfHook; }); diff --git a/src/cli/report.ts b/src/cli/report.ts new file mode 100644 index 00000000..72eda5a7 --- /dev/null +++ b/src/cli/report.ts @@ -0,0 +1,70 @@ +import * as path from "node:path"; +import { findProjectRoot } from "../scanner/project-root.js"; +import { readJSON } from "../utils/fs-safe.js"; + +// `openwolf report` — Workstream F1: the verifiable-numbers view. +// Estimated figures come from OpenWolf's char-ratio heuristics; real figures +// come from the harness transcripts (message usage summed by the stop hook). + +interface RealUsage { + input_tokens: number; + output_tokens: number; + cache_read_input_tokens: number; + cache_creation_input_tokens: number; + api_calls: number; +} + +interface Ledger { + lifetime: Record; + sessions: Array<{ + id: string; + ended: string; + totals: { input_tokens_estimated: number; output_tokens_estimated: number; reads_count: number; writes_count: number }; + real_usage?: RealUsage; + }>; +} + +const fmt = (n: number | undefined): string => (n ?? 0).toLocaleString("en-US"); + +export function reportCommand(): void { + const projectRoot = findProjectRoot(); + const ledger = readJSON(path.join(projectRoot, ".wolf", "token-ledger.json"), { + lifetime: {}, sessions: [], + }); + const lt = ledger.lifetime; + + console.log(""); + console.log(" OpenWolf token report"); + console.log(" ─────────────────────"); + console.log(` Sessions: ${fmt(lt.total_sessions)}`); + console.log(` Reads / writes: ${fmt(lt.total_reads)} / ${fmt(lt.total_writes)}`); + console.log(` Anatomy hits / misses: ${fmt(lt.anatomy_hits)} / ${fmt(lt.anatomy_misses)}`); + console.log(` Repeated reads blocked: ${fmt(lt.repeated_reads_blocked)}`); + console.log(""); + console.log(" Estimated (char-ratio heuristic)"); + console.log(` Total tokens: ${fmt(lt.total_tokens_estimated)}`); + console.log(` Est. savings vs bare: ${fmt(lt.estimated_savings_vs_bare_cli)}`); + console.log(""); + if (lt.real_api_calls) { + console.log(" Measured (from harness transcripts)"); + console.log(` API calls: ${fmt(lt.real_api_calls)}`); + console.log(` Input tokens: ${fmt(lt.real_input_tokens)}`); + console.log(` Output tokens: ${fmt(lt.real_output_tokens)}`); + console.log(` Cache reads: ${fmt(lt.real_cache_read_tokens)}`); + console.log(` Cache writes: ${fmt(lt.real_cache_creation_tokens)}`); + } else { + console.log(" Measured usage: none recorded yet — it accumulates as sessions"); + console.log(" end (the Stop hook reads real usage from the transcript)."); + } + + const withReal = ledger.sessions.filter((s) => s.real_usage); + if (withReal.length > 0) { + console.log(""); + console.log(" Last sessions (measured)"); + for (const s of withReal.slice(-5)) { + const r = s.real_usage!; + console.log(` ${s.ended?.slice(0, 16) ?? "?"} in ${fmt(r.input_tokens)} | out ${fmt(r.output_tokens)} | cache-read ${fmt(r.cache_read_input_tokens)} (${r.api_calls} calls)`); + } + } + console.log(""); +} diff --git a/src/cli/status.ts b/src/cli/status.ts index cbead460..0fb04c35 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -40,16 +40,14 @@ export async function statusCommand(): Promise { "post-read.js", "post-write.js", "stop.js", "shared.js", ]; const hooksDir = path.join(wolfDir, "hooks"); - for (const provider of ["claude", "codex"]) { - let hooksMissing = 0; - for (const file of hookFiles) { - if (!fs.existsSync(path.join(hooksDir, provider, file))) hooksMissing++; - } - if (hooksMissing === 0) { - console.log(` ✓ All ${hookFiles.length} ${provider} hook scripts present`); - } else { - console.log(` ✗ Missing ${hooksMissing} ${provider} hook scripts`); - } + let hooksMissing = 0; + for (const file of hookFiles) { + if (!fs.existsSync(path.join(hooksDir, file))) hooksMissing++; + } + if (hooksMissing === 0) { + console.log(` ✓ All ${hookFiles.length} hook scripts present`); + } else { + console.log(` ✗ Missing ${hooksMissing} hook scripts`); } // Claude settings check @@ -65,18 +63,6 @@ export async function statusCommand(): Promise { console.log(" ✗ .claude/settings.json not found"); } - const codexHooksPath = path.join(projectRoot, ".codex", "hooks.json"); - if (fs.existsSync(codexHooksPath)) { - const hooks = readJSON>(codexHooksPath, {}); - const configured = hooks.hooks as Record | undefined; - if (configured) { - const hookCount = Object.values(configured).reduce((sum, arr) => sum + arr.length, 0); - console.log(` ✓ Codex hooks registered (${hookCount} matchers)`); - } - } else { - console.log(" ✗ .codex/hooks.json not found"); - } - // Token ledger stats const ledger = readJSON<{ lifetime: { diff --git a/src/cli/update.ts b/src/cli/update.ts index 90503b4b..b7191f55 100644 --- a/src/cli/update.ts +++ b/src/cli/update.ts @@ -11,9 +11,11 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { getRegisteredProjects, registerProject, type RegisteredProject } from "./registry.js"; -import { readJSON, writeJSON, readText, writeText } from "../utils/fs-safe.js"; +import { readJSON, writeJSON, readText, writeText, safeCopyFile } from "../utils/fs-safe.js"; import { ensureDir } from "../utils/paths.js"; -import { getCodexConfigToml } from "./codex-config.js"; +import { resolveAgents, availableAgents } from "../agents/index.js"; +import { newStore, importFromMarkdown, saveStore, STORE_FILE, sha256 as storeSha256 } from "../hooks/anatomy-store.js"; +import { installSkills } from "../agents/skills.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -28,14 +30,24 @@ function getVersion(): string { } } -// Files that are safe to overwrite (protocol/config) -const ALWAYS_OVERWRITE = ["OPENWOLF.md", "config.json", "reframe-frameworks.md"]; - -// Files that contain user data — NEVER overwrite, only create if missing +// Files that are safe to overwrite (protocol docs only — never user-edited config) +const ALWAYS_OVERWRITE = ["OPENWOLF.md", "reframe-frameworks.md"]; + +// Files that contain user data — NEVER overwrite, only create if missing. +// +// `config.json` is user data: it holds per-project port assignments +// (`openwolf.daemon.port`, `openwolf.dashboard.port`), scan intervals, +// exclude patterns, and any other tunables a user has customized. +// Overwriting it on `openwolf update` resets every registered project to +// the same default ports (18790 / 18791), at which point only the first +// daemon to start can bind and the rest crash-loop on EADDRINUSE. +// Keep it in BACKUP_FILES (via the spread below) so `openwolf restore` +// can still recover it. const USER_DATA_FILES = [ - "identity.md", "cerebrum.md", "memory.md", "anatomy.md", + "config.json", + "identity.md", "cerebrum.md", "memory.md", "anatomy.md", "anatomy-index.json", "STATUS.md", "token-ledger.json", "buglog.json", "cron-manifest.json", "cron-state.json", - "suggestions.json", "designqc-report.json", + "suggestions.json", ]; // Files to include in backup @@ -44,35 +56,19 @@ const BACKUP_FILES = [ ...USER_DATA_FILES, ]; -const CLAUDE_HOOK_SETTINGS = { - hooks: { - SessionStart: [{ matcher: "", hooks: [{ type: "command", command: "node", args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/session-start.js", "${CLAUDE_PROJECT_DIR}"], timeout: 5 }] }], - PreToolUse: [ - { matcher: "Read", hooks: [{ type: "command", command: "node", args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/pre-read.js", "${CLAUDE_PROJECT_DIR}"], timeout: 5 }] }, - { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: "node", args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/pre-write.js", "${CLAUDE_PROJECT_DIR}"], timeout: 5 }] }, - ], - PostToolUse: [ - { matcher: "Read", hooks: [{ type: "command", command: "node", args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/post-read.js", "${CLAUDE_PROJECT_DIR}"], timeout: 5 }] }, - { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: "node", args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/post-write.js", "${CLAUDE_PROJECT_DIR}"], timeout: 10 }] }, - ], - Stop: [{ matcher: "", hooks: [{ type: "command", command: "node", args: ["${CLAUDE_PROJECT_DIR}/.wolf/hooks/claude/stop.js", "${CLAUDE_PROJECT_DIR}"], timeout: 10 }] }], - }, -}; - -const CODEX_PROJECT_ROOT = '$(git rev-parse --show-toplevel 2>/dev/null || pwd)'; - -const CODEX_HOOK_SETTINGS = { +const HOOK_SETTINGS = { hooks: { - SessionStart: [{ matcher: "startup|resume|clear", hooks: [{ type: "command", command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/session-start.js"`, timeout: 5, statusMessage: "OpenWolf session bootstrap" }] }], + SessionStart: [{ matcher: "", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/session-start.js"', timeout: 5 }] }], PreToolUse: [ - { matcher: "Read", hooks: [{ type: "command", command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/pre-read.js"`, timeout: 5, statusMessage: "OpenWolf read precheck" }] }, - { matcher: "Edit|Write|MultiEdit|apply_patch", hooks: [{ type: "command", command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/pre-write.js"`, timeout: 5, statusMessage: "OpenWolf write precheck" }] }, + { matcher: "Read", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-read.js"', timeout: 5 }] }, + { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/pre-write.js"', timeout: 5 }] }, ], PostToolUse: [ - { matcher: "Read", hooks: [{ type: "command", command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/post-read.js"`, timeout: 5, statusMessage: "OpenWolf read tracking" }] }, - { matcher: "Edit|Write|MultiEdit|apply_patch", hooks: [{ type: "command", command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/post-write.js"`, timeout: 10, statusMessage: "OpenWolf write tracking" }] }, + { matcher: "Read", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-read.js"', timeout: 5 }] }, + { matcher: "Write|Edit|MultiEdit", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/post-write.js"', timeout: 10 }] }, ], - Stop: [{ hooks: [{ type: "command", command: `node "${CODEX_PROJECT_ROOT}/.wolf/hooks/codex/stop.js"`, timeout: 10, statusMessage: "OpenWolf session finalize" }] }], + PreCompact: [{ matcher: "", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/precompact.js"', timeout: 5 }] }], + Stop: [{ matcher: "", hooks: [{ type: "command", command: 'node "$CLAUDE_PROJECT_DIR/.wolf/hooks/stop.js"', timeout: 10 }] }], }, }; @@ -188,7 +184,7 @@ async function updateProject( const srcPath = path.join(templatesDir, file); const destPath = path.join(wolfDir, file); if (fs.existsSync(srcPath)) { - fs.copyFileSync(srcPath, destPath); + safeCopyFile(srcPath, destPath); } } console.log(` ✓ Templates updated (${ALWAYS_OVERWRITE.join(", ")})`); @@ -203,10 +199,10 @@ async function updateProject( const settingsPath = path.join(claudeDir, "settings.json"); if (fs.existsSync(settingsPath)) { const existing = readJSON>(settingsPath, {}); - const merged = replaceOpenWolfHooks(existing, CLAUDE_HOOK_SETTINGS); + const merged = replaceOpenWolfHooks(existing, HOOK_SETTINGS); writeJSON(settingsPath, merged); } else { - writeJSON(settingsPath, CLAUDE_HOOK_SETTINGS); + writeJSON(settingsPath, HOOK_SETTINGS); } console.log(` ✓ Claude settings updated`); @@ -217,6 +213,64 @@ async function updateProject( writeText(path.join(rulesDir, "openwolf.md"), rulesContent); console.log(` ✓ Claude rules updated`); + // 5a2. One-time anatomy store migration (projects predating the durable + // store): bootstrap anatomy-index.json from the existing anatomy.md. + try { + if (!fs.existsSync(path.join(wolfDir, STORE_FILE))) { + const md = readText(path.join(wolfDir, "anatomy.md")); + if (md) { + const store = newStore(); + importFromMarkdown(store, md, root); + store.meta.renderedHash = storeSha256(md); + saveStore(wolfDir, store); + console.log(` ✓ anatomy-index.json created (migrated from anatomy.md)`); + } + } + } catch {} + + // 5a3. Resolve port collisions across registered projects. Projects + // upgraded from 1.x share the old default ports (18790/18791); when more + // than one is registered their daemons and dashboards collide, which is + // why the dashboard used to only ever open the first project. Reassign a + // free pair when this project's ports overlap another registered project. + try { + const reassigned = reassignCollidingPorts(root, wolfDir); + if (reassigned) console.log(` ✓ Ports reassigned to ${reassigned} (avoids collision with another project)`); + } catch {} + + // 5b. Create STATUS.md if missing (projects predating the handoff doc) + const statusPath = path.join(wolfDir, "STATUS.md"); + if (!fs.existsSync(statusPath)) { + const statusContent = readTemplateContent("STATUS.md", templatesDir); + if (statusContent) { + writeText(statusPath, seedStatusPlaceholders(statusContent, root)); + console.log(` ✓ STATUS.md created`); + } + } + + // 5c. Re-run the agent adapters this project was initialized with, so + // Codex/OpenCode/Gemini/Cursor wiring picks up new hooks and templates. + const cfg = readJSON<{ openwolf?: { agents?: string[] } }>(path.join(wolfDir, "config.json"), {}); + const agentNames = cfg.openwolf?.agents ?? ["claude"]; + const known = new Set(availableAgents()); + const extras = agentNames.filter((a) => a !== "claude"); + const unknown = extras.filter((a) => !known.has(a)); + for (const u of unknown) { + console.log(` ⚠ unknown agent "${u}" in config.openwolf.agents — skipped`); + } + const adapters = resolveAgents(extras.filter((a) => known.has(a))); + const ctx = { projectRoot: root, wolfDir, templatesDir }; + for (const adapter of adapters) { + const result = adapter.install(ctx); + for (const line of result.actions) console.log(` ✓ ${line}`); + for (const warn of result.warnings) console.log(` ⚠ ${adapter.displayName}: ${warn}`); + } + + // 5d. Refresh bundled skills for every configured agent. + for (const line of installSkills(root, templatesDir, agentNames)) { + console.log(` ✓ ${line}`); + } + // 6. Update CLAUDE.md snippet if it references OpenWolf const claudeMdPath = path.join(root, "CLAUDE.md"); const snippetContent = readTemplateContent("claude-md-snippet.md", templatesDir); @@ -226,32 +280,9 @@ async function updateProject( writeText(claudeMdPath, snippetContent + "\n\n" + existing); console.log(` ✓ CLAUDE.md updated`); } - } else { - writeText(claudeMdPath, snippetContent); - console.log(` ✓ CLAUDE.md created`); - } - - // 7. Update Codex hooks + AGENTS.md - const codexDir = path.join(root, ".codex"); - ensureDir(codexDir); - writeJSON(path.join(codexDir, "hooks.json"), CODEX_HOOK_SETTINGS); - writeText(path.join(codexDir, "config.toml"), getCodexConfigToml()); - console.log(` ✓ Codex hooks updated`); - - const agentsPath = path.join(root, "AGENTS.md"); - const agentsSnippet = readTemplateContent("agents-md-snippet.md", templatesDir); - if (fs.existsSync(agentsPath)) { - const existing = readText(agentsPath); - if (!existing.includes("OpenWolf")) { - writeText(agentsPath, agentsSnippet + "\n\n" + existing); - console.log(` ✓ AGENTS.md updated`); - } - } else { - writeText(agentsPath, agentsSnippet); - console.log(` ✓ AGENTS.md created`); } - // 8. Clean up stale .tmp files + // 7. Clean up stale .tmp files try { const files = fs.readdirSync(wolfDir); let cleaned = 0; @@ -263,7 +294,7 @@ async function updateProject( if (cleaned > 0) console.log(` ✓ Cleaned ${cleaned} stale .tmp file(s)`); } catch {} - // 9. Update registry entry + // 8. Update registry entry registerProject(root, name, version); return { @@ -278,6 +309,51 @@ async function updateProject( } } +/** + * If this project's dashboard/daemon ports collide with another registered + * project, allocate a free pair. Returns "daemon/dashboard" if reassigned, + * else null. Mirrors the free-port logic in cli/init.ts reconcileConfig. + */ +function reassignCollidingPorts(root: string, wolfDir: string): string | null { + const cfgPath = path.join(wolfDir, "config.json"); + const cfg = readJSON(cfgPath, null as any); + if (!cfg?.openwolf?.dashboard) return null; + + const norm = (p: string) => (process.platform === "win32" ? path.resolve(p).toLowerCase() : path.resolve(p)); + const mine = norm(root); + const used = new Set(); + for (const proj of getRegisteredProjects(false)) { + if (norm(proj.root) === mine) continue; + const oc = readJSON(path.join(proj.root, ".wolf", "config.json"), null as any)?.openwolf; + if (typeof oc?.daemon?.port === "number") used.add(oc.daemon.port); + if (typeof oc?.dashboard?.port === "number") used.add(oc.dashboard.port); + } + + const myDash = cfg.openwolf.dashboard.port; + const myDaemon = cfg.openwolf.daemon?.port; + if (!used.has(myDash) && (typeof myDaemon !== "number" || !used.has(myDaemon))) return null; + + const nextFree = (base: number): number => { let p = base; while (used.has(p)) p++; used.add(p); return p; }; + cfg.openwolf.daemon = cfg.openwolf.daemon || {}; + cfg.openwolf.dashboard = cfg.openwolf.dashboard || {}; + cfg.openwolf.daemon.port = nextFree(18790); + cfg.openwolf.dashboard.port = nextFree(18791); + writeJSON(cfgPath, cfg); + return `${cfg.openwolf.daemon.port}/${cfg.openwolf.dashboard.port}`; +} + +/** Fill STATUS.md template placeholders — mirrors seedStatus in init.ts. */ +function seedStatusPlaceholders(content: string, projectRoot: string): string { + let projectName = path.basename(projectRoot); + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf-8")); + if (typeof pkg.name === "string" && pkg.name) projectName = pkg.name; + } catch {} + return content + .replace(/\{\{PROJECT_NAME\}\}/g, projectName) + .replace(/\{\{DATE\}\}/g, new Date().toISOString().slice(0, 10)); +} + /** * Create a timestamped backup of all .wolf files into .wolf/backups/YYYY-MM-DD_HHMMSS/ */ @@ -291,7 +367,7 @@ function createBackup(wolfDir: string): string { for (const file of BACKUP_FILES) { const src = path.join(wolfDir, file); if (fs.existsSync(src)) { - fs.copyFileSync(src, path.join(backupDir, file)); + safeCopyFile(src, path.join(backupDir, file)); } } @@ -299,8 +375,15 @@ function createBackup(wolfDir: string): string { const hooksDir = path.join(wolfDir, "hooks"); if (fs.existsSync(hooksDir)) { const hooksBackup = path.join(backupDir, "hooks"); + ensureDir(hooksBackup); try { - copyDirectoryRecursive(hooksDir, hooksBackup); + const hookFiles = fs.readdirSync(hooksDir); + for (const f of hookFiles) { + const src = path.join(hooksDir, f); + if (fs.statSync(src).isFile()) { + safeCopyFile(src, path.join(hooksBackup, f)); + } + } } catch {} } @@ -310,30 +393,13 @@ function createBackup(wolfDir: string): string { if (fs.existsSync(claudeSettings)) { const claudeBackup = path.join(backupDir, ".claude"); ensureDir(claudeBackup); - fs.copyFileSync(claudeSettings, path.join(claudeBackup, "settings.json")); + safeCopyFile(claudeSettings, path.join(claudeBackup, "settings.json")); } const claudeRules = path.join(projectRoot, ".claude", "rules", "openwolf.md"); if (fs.existsSync(claudeRules)) { const rulesBackup = path.join(backupDir, ".claude", "rules"); ensureDir(rulesBackup); - fs.copyFileSync(claudeRules, path.join(rulesBackup, "openwolf.md")); - } - - const codexHooks = path.join(projectRoot, ".codex", "hooks.json"); - if (fs.existsSync(codexHooks)) { - const codexBackup = path.join(backupDir, ".codex"); - ensureDir(codexBackup); - fs.copyFileSync(codexHooks, path.join(codexBackup, "hooks.json")); - } - const codexConfig = path.join(projectRoot, ".codex", "config.toml"); - if (fs.existsSync(codexConfig)) { - const codexBackup = path.join(backupDir, ".codex"); - ensureDir(codexBackup); - fs.copyFileSync(codexConfig, path.join(codexBackup, "config.toml")); - } - const agentsPath = path.join(projectRoot, "AGENTS.md"); - if (fs.existsSync(agentsPath)) { - fs.copyFileSync(agentsPath, path.join(backupDir, "AGENTS.md")); + safeCopyFile(claudeRules, path.join(rulesBackup, "openwolf.md")); } return backupDir; @@ -361,8 +427,7 @@ function readTemplateContent(filename: string, templatesDir: string): string { } const templates: Record = { "claude-md-snippet.md": `# OpenWolf\n\n@.wolf/OPENWOLF.md\n\nThis project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md every session. Check .wolf/cerebrum.md before generating code. Check .wolf/anatomy.md before reading files.`, - "agents-md-snippet.md": `# OpenWolf\n\n@.wolf/OPENWOLF.md\n\nThis project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md every session. Check .wolf/cerebrum.md before generating code. Check .wolf/anatomy.md before reading files.`, - "claude-rules-openwolf.md": `---\ndescription: OpenWolf protocol enforcement — active on all files\nglobs: **/*\n---\n\n- Check .wolf/anatomy.md before reading any project file\n- Check .wolf/cerebrum.md Do-Not-Repeat list before generating code\n- After writing or editing files, update .wolf/anatomy.md and append to .wolf/memory.md\n- After receiving a user correction, update .wolf/cerebrum.md immediately (Preferences, Learnings, or Do-Not-Repeat)\n- LEARN from every interaction: if you discover a convention, user preference, or project pattern, add it to .wolf/cerebrum.md. Low threshold — when in doubt, log it.\n- BEFORE fixing any bug or error: read .wolf/buglog.json for known fixes\n- AFTER fixing any bug, error, failed test, failed build, or user-reported problem: ALWAYS log to .wolf/buglog.json with error_message, root_cause, fix, and tags\n- If you edit a file more than twice in a session, that likely indicates a bug — log it to .wolf/buglog.json\n- When the user asks to check/evaluate UI design: run \`openwolf designqc\` to capture screenshots, then read them from .wolf/designqc-captures/\n- When the user asks to change/pick/migrate UI framework: read .wolf/reframe-frameworks.md, ask decision questions, recommend a framework, then execute with the framework's prompt`, + "claude-rules-openwolf.md": `---\ndescription: OpenWolf protocol enforcement — active on all files\nglobs: **/*\n---\n\n- Check .wolf/anatomy.md before reading any project file\n- Check .wolf/cerebrum.md Do-Not-Repeat list before generating code\n- After writing or editing files, update .wolf/anatomy.md and append to .wolf/memory.md\n- After receiving a user correction, update .wolf/cerebrum.md immediately (Preferences, Learnings, or Do-Not-Repeat)\n- LEARN from every interaction: if you discover a convention, user preference, or project pattern, add it to .wolf/cerebrum.md. Low threshold — when in doubt, log it.\n- BEFORE fixing any bug or error: read .wolf/buglog.json for known fixes\n- AFTER fixing any bug, error, failed test, failed build, or user-reported problem: ALWAYS log to .wolf/buglog.json with error_message, root_cause, fix, and tags\n- If you edit a file more than twice in a session, that likely indicates a bug — log it to .wolf/buglog.json\n- When the user asks to change/pick/migrate UI framework: read .wolf/reframe-frameworks.md, ask decision questions, recommend a framework, then execute with the framework's prompt`, }; return templates[filename] ?? ""; } @@ -387,19 +452,15 @@ function copyHookScripts(wolfDir: string): void { const hookFiles = [ "session-start.js", "pre-read.js", "pre-write.js", - "post-read.js", "post-write.js", "stop.js", "shared.js", + "post-read.js", "post-write.js", "precompact.js", "stop.js", "shared.js", + "anatomy-store.js", "anatomy-lock.js", ]; if (sourceDir) { - removeLegacyTopLevelHooks(hooksDir, hookFiles); - for (const provider of ["claude", "codex"]) { - const providerDir = path.join(hooksDir, provider); - ensureDir(providerDir); - for (const file of hookFiles) { - const src = path.join(sourceDir, file); - if (fs.existsSync(src)) { - fs.copyFileSync(src, path.join(providerDir, file)); - } + for (const file of hookFiles) { + const src = path.join(sourceDir, file); + if (fs.existsSync(src)) { + safeCopyFile(src, path.join(hooksDir, file)); } } } @@ -409,33 +470,21 @@ function copyHookScripts(wolfDir: string): void { fs.writeFileSync(hooksPkgPath, JSON.stringify({ type: "module" }, null, 2) + "\n", "utf-8"); } -function removeLegacyTopLevelHooks(hooksDir: string, hookFiles: string[]): void { - for (const file of hookFiles) { - const legacyPath = path.join(hooksDir, file); - if (fs.existsSync(legacyPath)) { - try { - fs.unlinkSync(legacyPath); - } catch {} - } - } -} - function replaceOpenWolfHooks( existing: Record, - hookSettings: typeof CLAUDE_HOOK_SETTINGS + hookSettings: typeof HOOK_SETTINGS ): Record { const merged = { ...existing }; if (!merged.hooks) merged.hooks = {}; - const hooks = merged.hooks as Record }>>; + const hooks = merged.hooks as Record }>>; for (const [event, newMatchers] of Object.entries(hookSettings.hooks)) { if (!hooks[event]) hooks[event] = []; - // Remove existing OpenWolf hook entries (match by .wolf/hooks/ in command or args) + // Remove existing OpenWolf hook entries hooks[event] = hooks[event].filter((entry) => { const isOpenWolfHook = entry.hooks?.some( - (h) => (h.command && h.command.includes(".wolf/hooks/")) || - (h.args && h.args.some((a: string) => a.includes(".wolf/hooks/"))) + (h) => h.command && h.command.includes(".wolf/hooks/") ); return !isOpenWolfHook; }); @@ -512,14 +561,18 @@ export function restoreCommand(backupName?: string): void { // Restore files const files = fs.readdirSync(backupDir).filter(f => fs.statSync(path.join(backupDir, f)).isFile()); for (const file of files) { - fs.copyFileSync(path.join(backupDir, file), path.join(wolfDir, file)); + safeCopyFile(path.join(backupDir, file), path.join(wolfDir, file)); } // Restore hooks if present const hooksBackup = path.join(backupDir, "hooks"); if (fs.existsSync(hooksBackup)) { + const hookFiles = fs.readdirSync(hooksBackup); const hooksDir = path.join(wolfDir, "hooks"); - copyDirectoryRecursive(hooksBackup, hooksDir); + ensureDir(hooksDir); + for (const f of hookFiles) { + safeCopyFile(path.join(hooksBackup, f), path.join(hooksDir, f)); + } } // Restore .claude settings if present @@ -530,50 +583,15 @@ export function restoreCommand(backupName?: string): void { if (fs.existsSync(settingsBackup)) { const dest = path.join(projectRoot, ".claude", "settings.json"); ensureDir(path.dirname(dest)); - fs.copyFileSync(settingsBackup, dest); + safeCopyFile(settingsBackup, dest); } const rulesBackup = path.join(claudeBackup, "rules", "openwolf.md"); if (fs.existsSync(rulesBackup)) { const dest = path.join(projectRoot, ".claude", "rules", "openwolf.md"); ensureDir(path.dirname(dest)); - fs.copyFileSync(rulesBackup, dest); - } - } - - const codexBackup = path.join(backupDir, ".codex"); - if (fs.existsSync(codexBackup)) { - const projectRoot = path.dirname(wolfDir); - const hooksBackup = path.join(codexBackup, "hooks.json"); - if (fs.existsSync(hooksBackup)) { - const dest = path.join(projectRoot, ".codex", "hooks.json"); - ensureDir(path.dirname(dest)); - fs.copyFileSync(hooksBackup, dest); + safeCopyFile(rulesBackup, dest); } - const configBackup = path.join(codexBackup, "config.toml"); - if (fs.existsSync(configBackup)) { - const dest = path.join(projectRoot, ".codex", "config.toml"); - ensureDir(path.dirname(dest)); - fs.copyFileSync(configBackup, dest); - } - } - - const agentsBackup = path.join(backupDir, "AGENTS.md"); - if (fs.existsSync(agentsBackup)) { - fs.copyFileSync(agentsBackup, path.join(path.dirname(wolfDir), "AGENTS.md")); } console.log(`Restored ${files.length} files from backup "${backupName}".`); } - -function copyDirectoryRecursive(srcDir: string, destDir: string): void { - ensureDir(destDir); - for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) { - const src = path.join(srcDir, entry.name); - const dest = path.join(destDir, entry.name); - if (entry.isDirectory()) { - copyDirectoryRecursive(src, dest); - } else if (entry.isFile()) { - fs.copyFileSync(src, dest); - } - } -} diff --git a/src/daemon/cron-engine.ts b/src/daemon/cron-engine.ts index 1acf5a5d..b8db4f5b 100644 --- a/src/daemon/cron-engine.ts +++ b/src/daemon/cron-engine.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { execSync, spawnSync } from "node:child_process"; -import cron from "node-cron"; +import { execFileSync, spawnSync } from "node:child_process"; +import cron, { type ScheduledTask } from "node-cron"; import { readJSON, writeJSON, readText, writeText, appendText } from "../utils/fs-safe.js"; import { scanProject } from "../scanner/anatomy-scanner.js"; import { detectWaste } from "../tracker/waste-detector.js"; @@ -49,7 +49,7 @@ export class CronEngine { private projectRoot: string; private logger: Logger; private broadcast: (msg: unknown) => void; - private scheduledTasks: cron.ScheduledTask[] = []; + private scheduledTasks: ScheduledTask[] = []; private failureCounts = new Map(); constructor( @@ -297,8 +297,7 @@ export class CronEngine { private hasClaude(): boolean { try { - const cmd = process.platform === "win32" ? "where claude" : "which claude"; - execSync(cmd, { stdio: "ignore" }); + execFileSync(process.platform === "win32" ? "where" : "which", ["claude"], { stdio: "ignore" }); return true; } catch { return false; @@ -311,12 +310,15 @@ export class CronEngine { } const contextParts: string[] = []; - for (const file of params.context_files) { - const filePath = path.join(this.projectRoot, file); + const contextFiles = Array.isArray(params.context_files) ? params.context_files : []; + for (const file of contextFiles) { + if (typeof file !== "string") continue; try { + const filePath = this.resolveProjectContextFile(file); contextParts.push(`--- ${file} ---\n${fs.readFileSync(filePath, "utf-8")}`); - } catch { - contextParts.push(`--- ${file} --- (not found)`); + } catch (err) { + const reason = err instanceof Error ? err.message : "not found"; + contextParts.push(`--- ${file} --- (${reason})`); } } @@ -330,15 +332,14 @@ export class CronEngine { const env = { ...process.env }; delete env.ANTHROPIC_API_KEY; - const proc = spawnSync("claude -p --output-format text", { + const claudeBin = process.platform === "win32" ? "claude.cmd" : "claude"; + const proc = spawnSync(claudeBin, ["-p", "--output-format", "text"], { input: fullPrompt, timeout: 120000, encoding: "utf-8", cwd: this.projectRoot, env, stdio: ["pipe", "pipe", "pipe"], - // shell: true needed on Windows so that claude.cmd is resolved - shell: true, windowsHide: true, }); @@ -378,4 +379,21 @@ export class CronEngine { throw new Error(`claude -p failed: ${err instanceof Error ? err.message : String(err)}`); } } + + private resolveProjectContextFile(file: string): string { + if (file.includes("\0")) { + throw new Error("invalid path"); + } + + const root = fs.realpathSync(this.projectRoot); + const requested = path.resolve(this.projectRoot, file); + const resolved = fs.realpathSync(requested); + const relative = path.relative(root, resolved); + + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("outside project root"); + } + + return resolved; + } } diff --git a/src/daemon/file-watcher.ts b/src/daemon/file-watcher.ts index 3588277b..d68f2d72 100644 --- a/src/daemon/file-watcher.ts +++ b/src/daemon/file-watcher.ts @@ -29,6 +29,13 @@ export function startFileWatcher( logger.debug(`File changed: ${relativePath}`); try { + // DoS guard: never broadcast files above 1 MB to dashboard clients + const stat = fs.statSync(filePath as string); + if (stat.size > 1024 * 1024) { + logger.warn(`Skipping broadcast for large file: ${relativePath} (${stat.size} bytes)`); + return; + } + const content = fs.readFileSync(filePath as string, "utf-8"); broadcast({ type: "file_changed", diff --git a/src/daemon/wolf-daemon.ts b/src/daemon/wolf-daemon.ts index 6a3c93f6..6024b1ee 100644 --- a/src/daemon/wolf-daemon.ts +++ b/src/daemon/wolf-daemon.ts @@ -2,10 +2,12 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import express from "express"; +import type { Request, Response, NextFunction } from "express"; import { WebSocketServer, WebSocket } from "ws"; import { findProjectRoot } from "../scanner/project-root.js"; import { readJSON, writeJSON, readText } from "../utils/fs-safe.js"; import { Logger } from "../utils/logger.js"; +import { getDashboardToken, validateDashboardToken } from "../utils/dashboard-auth.js"; import { CronEngine } from "./cron-engine.js"; import { startFileWatcher } from "./file-watcher.js"; @@ -19,7 +21,7 @@ const wolfDir = path.join(projectRoot, ".wolf"); interface WolfConfig { openwolf: { daemon: { port: number; log_level: string }; - dashboard: { enabled: boolean; port: number }; + dashboard: { enabled: boolean; port: number; host?: string }; cron: { enabled: boolean; heartbeat_interval_minutes: number }; }; } @@ -39,11 +41,41 @@ const logger = new Logger( const startTime = Date.now(); const wsClients = new Set(); +getDashboardToken(wolfDir); // Express server const app = express(); app.use(express.json()); +function isAllowedOrigin(origin: string | undefined): boolean { + if (!origin) return true; + try { + const url = new URL(origin); + return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"; + } catch { + return false; + } +} + +function extractBearerToken(req: Request): string | null { + const auth = req.header("authorization") ?? ""; + if (auth.startsWith("Bearer ")) return auth.slice("Bearer ".length).trim(); + const queryToken = req.query.token; + return typeof queryToken === "string" ? queryToken : null; +} + +function requireDashboardAuth(req: Request, res: Response, next: NextFunction): void { + if (!isAllowedOrigin(req.header("origin"))) { + res.status(403).json({ error: "Forbidden origin" }); + return; + } + if (!validateDashboardToken(wolfDir, extractBearerToken(req))) { + res.status(401).json({ error: "Dashboard token required" }); + return; + } + next(); +} + // Serve dashboard static files // In dist: dist/src/daemon/wolf-daemon.js → ../../../dist/dashboard/ const dashboardDir = path.resolve(__dirname, "..", "..", "..", "dist", "dashboard"); @@ -105,6 +137,8 @@ function detectProjectMeta(): { name: string; description: string } { const projectMeta = detectProjectMeta(); // API routes +app.use("/api", requireDashboardAuth); + app.get("/api/health", (_req, res) => { const cronState = readJSON<{ engine_status: string; last_heartbeat: string | null; dead_letter_queue: unknown[] }>( path.join(wolfDir, "cron-state.json"), @@ -137,8 +171,7 @@ app.get("/api/files", (_req, res) => { const wolfFiles = [ "OPENWOLF.md", "identity.md", "cerebrum.md", "memory.md", "anatomy.md", "config.json", "token-ledger.json", "buglog.json", - "cron-manifest.json", "cron-state.json", - "designqc-report.json", + "cron-manifest.json", "cron-state.json", "STATUS.md", "_scan-state.json", "anatomy-index.json", ]; for (const file of wolfFiles) { try { @@ -156,11 +189,6 @@ app.get("/api/files", (_req, res) => { res.json(files); }); -app.get("/api/designqc-report", (_req, res) => { - const report = readJSON(path.join(wolfDir, "designqc-report.json"), null); - res.json(report); -}); - // Trigger a cron task by ID app.post("/api/cron/run/:taskId", (req, res) => { const { taskId } = req.params; @@ -185,14 +213,36 @@ app.get("/{*path}", (_req, res) => { } }); -// Start HTTP server -const port = config.openwolf.dashboard.port; -const server = app.listen(port, () => { - logger.info(`Dashboard server listening on port ${port}`); +// Start HTTP server. OPENWOLF_DASHBOARD_PORT lets the launcher override the +// configured port when it is already held by another project's daemon. +const envPort = Number(process.env.OPENWOLF_DASHBOARD_PORT); +const port = Number.isInteger(envPort) && envPort > 0 ? envPort : config.openwolf.dashboard.port; +const host = config.openwolf.dashboard.host || "127.0.0.1"; +const server = app.listen(port, host, () => { + logger.info(`Dashboard server listening on ${host}:${port}`); }); // WebSocket server -const wss = new WebSocketServer({ server }); +const wss = new WebSocketServer({ + server, + verifyClient: (info, done) => { + if (!isAllowedOrigin(info.origin)) { + done(false, 403, "Forbidden origin"); + return; + } + try { + const url = new URL(info.req.url ?? "", `http://${info.req.headers.host ?? "localhost"}`); + if (!validateDashboardToken(wolfDir, url.searchParams.get("token"))) { + done(false, 401, "Dashboard token required"); + return; + } + } catch { + done(false, 401, "Dashboard token required"); + return; + } + done(true); + }, +}); wss.on("connection", (ws) => { wsClients.add(ws); @@ -259,8 +309,7 @@ function handleDashboardCommand(msg: { type: string; task_id?: string }): void { const wolfFiles = [ "OPENWOLF.md", "identity.md", "cerebrum.md", "memory.md", "anatomy.md", "config.json", "token-ledger.json", "buglog.json", - "cron-manifest.json", "cron-state.json", - "designqc-report.json", + "cron-manifest.json", "cron-state.json", "STATUS.md", "_scan-state.json", "anatomy-index.json", ]; for (const file of wolfFiles) { try { diff --git a/src/dashboard/app/App.tsx b/src/dashboard/app/App.tsx index 2bc426e3..5a9b5cfb 100644 --- a/src/dashboard/app/App.tsx +++ b/src/dashboard/app/App.tsx @@ -1,7 +1,6 @@ import React, { useState, Suspense, lazy } from "react"; -import { Sidebar } from "./components/layout/Sidebar.js"; +import { TopNav } from "./components/layout/TopNav.js"; import { Layout } from "./components/layout/Layout.js"; -import { Header } from "./components/layout/Header.js"; import { useWolfData } from "./hooks/useWolfData.js"; import { useTheme } from "./hooks/useTheme.js"; @@ -14,37 +13,31 @@ const MemoryViewer = lazy(() => import("./components/panels/MemoryViewer.js").th const AnatomyBrowser = lazy(() => import("./components/panels/AnatomyBrowser.js").then(m => ({ default: m.AnatomyBrowser }))); const BugLog = lazy(() => import("./components/panels/BugLog.js").then(m => ({ default: m.BugLog }))); const AISuggestions = lazy(() => import("./components/panels/AISuggestions.js").then(m => ({ default: m.AISuggestions }))); -const DesignQC = lazy(() => import("./components/panels/DesignQC.js").then(m => ({ default: m.DesignQC }))); - -const panelTitles: Record = { - overview: "Overview", - activity: "Activity Timeline", - tokens: "Token Intelligence", - cron: "Cron Control Center", - cerebrum: "Cerebrum", - memory: "Memory Browser", - anatomy: "Anatomy Browser", - bugs: "Bug Log", - suggestions: "AI Insights", - designqc: "Design QC", -}; function Skeleton() { return (
-
-
+
+
-
-
-
+
+
+
); } +const PANELS = ["overview", "activity", "tokens", "cron", "cerebrum", "memory", "anatomy", "bugs", "suggestions"]; + export default function App() { - const [activePanel, setActivePanel] = useState("overview"); + // Hash-based deep links: /#tokens opens the Tokens panel directly. + const initial = location.hash.slice(1); + const [activePanel, setActivePanelState] = useState(PANELS.includes(initial) ? initial : "overview"); + const setActivePanel = (p: string) => { + setActivePanelState(p); + history.replaceState(null, "", `#${p}`); + }; const data = useWolfData(); const { theme, toggleTheme } = useTheme(); @@ -52,8 +45,24 @@ export default function App() { return (
-
🐺
-

Loading OpenWolf...

+
OPENWOLF
+

loading…

+
+
+ ); + } + + if (data.authError) { + return ( +
+
+
401
+

Dashboard token rejected.

+

+ The server on this port belongs to a different project or an older session. + Close this tab and relaunch with openwolf dashboard from + your project to open the correct URL with a matching token. +

); @@ -61,16 +70,16 @@ export default function App() { return (
- -
}> {activePanel === "overview" && } {activePanel === "activity" && } @@ -81,7 +90,6 @@ export default function App() { {activePanel === "anatomy" && } {activePanel === "bugs" && } {activePanel === "suggestions" && } - {activePanel === "designqc" && }
diff --git a/src/dashboard/app/components/layout/Header.tsx b/src/dashboard/app/components/layout/Header.tsx deleted file mode 100644 index 0b055f76..00000000 --- a/src/dashboard/app/components/layout/Header.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from "react"; -import { LiveIndicator } from "../shared/LiveIndicator.js"; -import type { Theme } from "../../hooks/useTheme.js"; - -interface HeaderProps { - title: string; - theme: Theme; - onToggleTheme: () => void; -} - -export function Header({ title, theme, onToggleTheme }: HeaderProps) { - return ( -
-

{title}

-
- - -
-
- ); -} diff --git a/src/dashboard/app/components/layout/Layout.tsx b/src/dashboard/app/components/layout/Layout.tsx index 983c7b89..e1cea26a 100644 --- a/src/dashboard/app/components/layout/Layout.tsx +++ b/src/dashboard/app/components/layout/Layout.tsx @@ -2,7 +2,7 @@ import React from "react"; export function Layout({ children }: { children: React.ReactNode }) { return ( -
+
{children}
); diff --git a/src/dashboard/app/components/layout/Sidebar.tsx b/src/dashboard/app/components/layout/Sidebar.tsx deleted file mode 100644 index f7eac75a..00000000 --- a/src/dashboard/app/components/layout/Sidebar.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from "react"; -import { cn } from "../../lib/utils.js"; -import { StatusBadge } from "../shared/StatusBadge.js"; -import type { Theme } from "../../hooks/useTheme.js"; - -const navItems = [ - { id: "overview", label: "Overview", icon: "◉" }, - { id: "activity", label: "Activity", icon: "◷" }, - { id: "tokens", label: "Tokens", icon: "◈" }, - { id: "cron", label: "Cron", icon: "⟳" }, - { id: "cerebrum", label: "Cerebrum", icon: "◎" }, - { id: "memory", label: "Memory", icon: "▤" }, - { id: "anatomy", label: "Anatomy", icon: "⊞" }, - { id: "bugs", label: "Bug Log", icon: "⚑" }, - { id: "suggestions", label: "AI Insights", icon: "✦" }, - { id: "designqc", label: "Design QC", icon: "🎨" }, -]; - -interface SidebarProps { - activePanel: string; - onNavigate: (panel: string) => void; - daemonStatus: string; - projectName: string; - theme: Theme; - onToggleTheme: () => void; -} - -export function Sidebar({ activePanel, onNavigate, daemonStatus, projectName, theme, onToggleTheme }: SidebarProps) { - return ( - <> - {/* Desktop sidebar */} - - - {/* Mobile bottom bar */} - - - ); -} diff --git a/src/dashboard/app/components/layout/TopNav.tsx b/src/dashboard/app/components/layout/TopNav.tsx new file mode 100644 index 00000000..0eb95fc9 --- /dev/null +++ b/src/dashboard/app/components/layout/TopNav.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { LiveIndicator } from "../shared/LiveIndicator.js"; +import type { Theme } from "../../hooks/useTheme.js"; + +const NAV_ITEMS = [ + { id: "overview", label: "Overview" }, + { id: "tokens", label: "Tokens" }, + { id: "activity", label: "Activity" }, + { id: "cron", label: "Cron" }, + { id: "cerebrum", label: "Cerebrum" }, + { id: "memory", label: "Memory" }, + { id: "anatomy", label: "Anatomy" }, + { id: "bugs", label: "Bugs" }, + { id: "suggestions", label: "Insights" }, +]; + +interface TopNavProps { + activePanel: string; + onNavigate: (panel: string) => void; + daemonStatus: string; + projectName: string; + agents: string[]; + theme: Theme; + onToggleTheme: () => void; +} + +export function TopNav({ activePanel, onNavigate, daemonStatus, projectName, agents, theme, onToggleTheme }: TopNavProps) { + return ( +
+
+
+ {/* Wordmark */} + + + {/* Right cluster */} +
+ + {projectName} + + {agents.length > 0 && ( +
+ {agents.map((a) => ( + + {a.slice(0, 2).toUpperCase()} + + ))} +
+ )} + + + {daemonStatus === "ok" || daemonStatus === "running" ? "daemon on" : `daemon ${daemonStatus}`} + + +
+
+ + {/* Nav row */} + +
+
+ ); +} diff --git a/src/dashboard/app/components/panels/AISuggestions.tsx b/src/dashboard/app/components/panels/AISuggestions.tsx index 6476ea42..20827a89 100644 --- a/src/dashboard/app/components/panels/AISuggestions.tsx +++ b/src/dashboard/app/components/panels/AISuggestions.tsx @@ -4,7 +4,7 @@ import type { WolfData } from "../../hooks/useWolfData.js"; const sections = [ { key: "achievements", title: "Achievements", icon: "🏆", color: "#059669" }, - { key: "improvements", title: "Improvements", icon: "✨", color: "#3b82f6" }, + { key: "improvements", title: "Improvements", icon: "◆", color: "var(--text-secondary)" }, { key: "next_tasks", title: "Next Tasks", icon: "📋", color: "#d97706" }, { key: "risks", title: "Risks & Tech Debt", icon: "🛡", color: "#dc2626" }, ] as const; diff --git a/src/dashboard/app/components/panels/ActivityTimeline.tsx b/src/dashboard/app/components/panels/ActivityTimeline.tsx index e4c4d37a..06930ccc 100644 --- a/src/dashboard/app/components/panels/ActivityTimeline.tsx +++ b/src/dashboard/app/components/panels/ActivityTimeline.tsx @@ -62,8 +62,8 @@ export function ActivityTimeline({ data }: { data: WolfData }) { {session.entries.map((entry, ei) => (
{entry.time} - + {entry.action} {entry.tokens}
diff --git a/src/dashboard/app/components/panels/AnatomyBrowser.tsx b/src/dashboard/app/components/panels/AnatomyBrowser.tsx index b4bfa64c..bb819b84 100644 --- a/src/dashboard/app/components/panels/AnatomyBrowser.tsx +++ b/src/dashboard/app/components/panels/AnatomyBrowser.tsx @@ -6,7 +6,7 @@ interface TreeNode { name: string; path: string; children: TreeNode[]; - files: Array<{ file: string; description: string; tokens: number }>; + files: Array<{ file: string; description: string; tokens: number; symbols?: Array<{ name: string; kind: string; startLine: number; endLine: number; tokens: number }> }>; } function buildTree(entries: WolfData["anatomy"]["entries"]): TreeNode { @@ -31,7 +31,7 @@ function buildTree(entries: WolfData["anatomy"]["entries"]): TreeNode { current = sectionMap.get(key)!; } } - sectionMap.get(section)!.files.push({ file: entry.file, description: entry.description, tokens: entry.tokens }); + sectionMap.get(section)!.files.push({ file: entry.file, description: entry.description, tokens: entry.tokens, symbols: entry.symbols }); } return root; @@ -53,7 +53,7 @@ function DirNode({ node, search, depth = 0 }: { node: TreeNode; search: string; @@ -61,11 +61,25 @@ function DirNode({ node, search, depth = 0 }: { node: TreeNode; search: string; {(expanded || node.name === ".") && (
{matchedFiles.sort((a, b) => a.file.localeCompare(b.file)).map((f) => ( -
- 📄 - {f.file} - {f.description && — {f.description}} - +
+
+ + {f.file} + {f.description && — {f.description}} + +
+ {f.symbols && f.symbols.length > 0 && ( +
+ {f.symbols.slice(0, 8).map((s) => ( + + {s.kind} {s.name} L{s.startLine}-{s.endLine} + + ))} + {f.symbols.length > 8 && ( + +{f.symbols.length - 8} more + )} +
+ )}
))} {node.children.sort((a, b) => a.name.localeCompare(b.name)).map((child) => ( diff --git a/src/dashboard/app/components/panels/CerebrumViewer.tsx b/src/dashboard/app/components/panels/CerebrumViewer.tsx index 4a1ed8f6..63710fd1 100644 --- a/src/dashboard/app/components/panels/CerebrumViewer.tsx +++ b/src/dashboard/app/components/panels/CerebrumViewer.tsx @@ -54,7 +54,7 @@ export function CerebrumViewer({ data }: { data: WolfData }) { onMouseEnter={e => (e.currentTarget.style.background = "var(--bg-surface-hover)")} onMouseLeave={e => (e.currentTarget.style.background = "transparent")}>
- +

User Preferences

{cerebrum.preferences.length}
@@ -101,7 +101,7 @@ export function CerebrumViewer({ data }: { data: WolfData }) { onMouseEnter={e => (e.currentTarget.style.background = "var(--bg-surface-hover)")} onMouseLeave={e => (e.currentTarget.style.background = "transparent")}>
- +

Decision Log

{cerebrum.decisions.length}
diff --git a/src/dashboard/app/components/panels/CronStatus.tsx b/src/dashboard/app/components/panels/CronStatus.tsx index 446fdfbd..9dfbecc7 100644 --- a/src/dashboard/app/components/panels/CronStatus.tsx +++ b/src/dashboard/app/components/panels/CronStatus.tsx @@ -1,12 +1,14 @@ import React, { useState } from "react"; import { StatusBadge } from "../shared/StatusBadge.js"; import { relativeTime, formatSchedule } from "../../lib/utils.js"; +import { dashboardFetch } from "../../lib/wolf-client.js"; import type { WolfData } from "../../hooks/useWolfData.js"; export function CronStatus({ data }: { data: WolfData }) { const { cronManifest, cronState, client } = data; const [showDeadLetters, setShowDeadLetters] = useState(true); const [showHistory, setShowHistory] = useState(false); + const [runningTasks, setRunningTasks] = useState>({}); const getTaskStatus = (taskId: string): string => { if (cronState.dead_letter_queue.some((d: any) => d.task_id === taskId)) return "failed"; @@ -19,8 +21,15 @@ export function CronStatus({ data }: { data: WolfData }) { return last ? relativeTime(last.timestamp) : "never"; }; + // Run Now over authenticated HTTP (from PR #4 by @MyEditHub): the old + // client?.send() silently dropped when the WebSocket wasn't OPEN. + const clearSoon = (taskId: string) => + setTimeout(() => setRunningTasks(prev => { const n = { ...prev }; delete n[taskId]; return n; }), 3000); const triggerTask = (taskId: string) => { - client?.send({ type: "trigger_task", task_id: taskId }); + setRunningTasks(prev => ({ ...prev, [taskId]: "running" })); + dashboardFetch(`/api/cron/run/${encodeURIComponent(taskId)}`, { method: "POST" }) + .then(r => { setRunningTasks(prev => ({ ...prev, [taskId]: r.ok ? "ok" : "error" })); clearSoon(taskId); }) + .catch(() => { setRunningTasks(prev => ({ ...prev, [taskId]: "error" })); clearSoon(taskId); }); }; const retryDeadLetter = (taskId: string) => { @@ -55,9 +64,15 @@ export function CronStatus({ data }: { data: WolfData }) { {getLastRun(task.id)} + style={{ + background: "var(--bg-surface-hover)", + border: "1px solid var(--border-subtle)", + color: runningTasks[task.id] === "error" ? "var(--danger, #e5484d)" : "var(--text-secondary)", + opacity: runningTasks[task.id] === "running" ? 0.6 : 1, + }} + >{runningTasks[task.id] === "running" ? "Running…" : runningTasks[task.id] === "ok" ? "✓ Queued" : runningTasks[task.id] === "error" ? "✗ Failed" : "Run Now"} ))} diff --git a/src/dashboard/app/components/panels/DesignQC.tsx b/src/dashboard/app/components/panels/DesignQC.tsx deleted file mode 100644 index b9cb3bcb..00000000 --- a/src/dashboard/app/components/panels/DesignQC.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React from "react"; -import type { WolfData } from "../../hooks/useWolfData.js"; - -export function DesignQC({ data }: { data: WolfData }) { - const { designqcReport } = data; - - const hasCaptures = designqcReport && designqcReport.captures && designqcReport.captures.length > 0; - - return ( -
- {/* How it works */} -
-

How Design QC Works

-
-

1. Run openwolf designqc --url http://localhost:3000

-

2. OpenWolf captures compressed screenshots of your app

-

3. In your Claude Code session, ask Claude to evaluate the screenshots:

-

"Read the screenshots in .wolf/designqc-captures/ and evaluate the design"

-

4. Claude sees the images, evaluates design, and can fix issues right in your session

-
-
- - {/* Capture status */} -
-

Last Capture

- {!hasCaptures ? ( -
-

- No screenshots captured yet. -

-
- ) : ( -
-
- - Captured: {designqcReport.captured_at || "—"} - - - Size: {designqcReport.total_size_kb || 0}KB - - - Est. tokens: ~{designqcReport.estimated_tokens || 0} - -
- {designqcReport.captures.map((cap: any, i: number) => ( -
- {cap.file} - {cap.viewport} · {cap.route} -
- ))} -
- )} -
-
- ); -} diff --git a/src/dashboard/app/components/panels/ProjectOverview.tsx b/src/dashboard/app/components/panels/ProjectOverview.tsx index 4589aba0..de3cae12 100644 --- a/src/dashboard/app/components/panels/ProjectOverview.tsx +++ b/src/dashboard/app/components/panels/ProjectOverview.tsx @@ -1,66 +1,202 @@ import React from "react"; import { StatusBadge } from "../shared/StatusBadge.js"; -import { relativeTime, formatTokens } from "../../lib/utils.js"; +import { StatTile } from "../shared/StatTile.js"; +import { DotBar, type DotBarDatum } from "../shared/DotBar.js"; +import { formatTokens } from "../../lib/utils.js"; import type { WolfData } from "../../hooks/useWolfData.js"; +function fmt(n: number | undefined): string { + return (n ?? 0).toLocaleString("en-US"); +} + +/** Sessions per weekday over the ledger's recent history → dot chart. */ +function weeklyActivity(data: WolfData): DotBarDatum[] { + const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + const counts = new Array(7).fill(0); + for (const s of data.tokenLedger.sessions.slice(-60)) { + const d = new Date(s.ended ?? s.started); + if (!isNaN(d.getTime())) counts[(d.getDay() + 6) % 7]++; + } + const today = (new Date().getDay() + 6) % 7; + return days.map((label, i) => ({ label, value: counts[i], highlight: i === today })); +} + +/** + * Extract the "Next phase" section from STATUS.md for the handoff card. + * Unfilled template placeholders (`_<...>_`) are dropped; markdown emphasis + * is stripped for plain rendering. + */ +function nextPhase(statusDoc: string): string[] { + const lines = statusDoc.split(/\r?\n/); + const start = lines.findIndex((l) => /^## 🚀/.test(l)); + if (start === -1) return []; + const out: string[] = []; + for (let i = start + 1; i < lines.length && out.length < 5; i++) { + if (/^## |^---\s*$/.test(lines[i])) break; + let t = lines[i].trim(); + if (!t) continue; + if (/<[^>]*>/.test(t)) continue; // unfilled template placeholder + if (t.startsWith("|")) continue; // markdown table markup + t = t.replace(/^#+\s*/, "").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/_([^_]+)_/g, "$1"); + if (t) out.push(t); + } + // Only headings survived → the section is still the empty template. + const meaningful = out.some((l) => !/^(Acceptance criteria|Files to create \/ edit|Open decisions|Closed decisions)$/i.test(l)); + return meaningful ? out : []; +} + export function ProjectOverview({ data }: { data: WolfData }) { - const { identity, health, tokenLedger, anatomy, memory, project } = data; + const { health, tokenLedger, anatomy, buglog, cronState, config, statusDoc, scanState, project, identity } = data; const lt = tokenLedger.lifetime; const projectName = project.name || identity.name; - const projectDesc = project.description || ""; + const measured = (lt.real_api_calls ?? 0) > 0; + const savingsPct = lt.total_tokens_estimated > 0 + ? Math.round((lt.estimated_savings_vs_bare_cli / (lt.total_tokens_estimated + lt.estimated_savings_vs_bare_cli)) * 100) + : 0; + const anatomyTotal = lt.anatomy_hits + lt.anatomy_misses; + const hitRate = anatomyTotal > 0 ? Math.round((lt.anatomy_hits / anatomyTotal) * 100) : null; + const scanAgeH = scanState.last_scanned + ? Math.floor((Date.now() - new Date(scanState.last_scanned).getTime()) / 3600000) + : null; + const phase = nextPhase(statusDoc); return ( -
- {/* Hero */} -
-

{projectName}

- {projectDesc &&

{projectDesc}

} -
+
+ {/* Header row */} +
+
+

{projectName}

+

+ one brain · {config.agents.length} agent{config.agents.length === 1 ? "" : "s"} wired +

+
+
{health.uptime_seconds > 0 && ( - Uptime: {Math.floor(health.uptime_seconds / 3600)}h {Math.floor((health.uptime_seconds % 3600) / 60)}m + + up {Math.floor(health.uptime_seconds / 3600)}h {Math.floor((health.uptime_seconds % 3600) / 60)}m + )}
- {/* Stat cards */} -
-
-

Files Tracked

-

{anatomy.metadata.files}

+ {/* Bento: hero + measured + agents */} +
+ {/* Hero — the inverted tile */} +
+ 0 ? formatTokens(lt.estimated_savings_vs_bare_cli) : "0"} + sub={savingsPct > 0 ? `${savingsPct}% vs bare agent` : "accumulates as sessions run"} + variant="inverted" + size="xl" + /> +
+ + {/* Measured usage */} +
+
+ measured · transcripts + {measured && } +
+ {measured ? ( +
+
in{fmt(lt.real_input_tokens)}
+
out{fmt(lt.real_output_tokens)}
+
cache read{fmt(lt.real_cache_read_tokens)}
+
api calls{fmt(lt.real_api_calls)}
+
+ ) : ( +

+ No measured usage yet — real token counts are read from each session's transcript at Stop. +

+ )} +
+ + {/* Agents */} +
+ agents +
+ {config.agents.map((a) => ( + + + {a.slice(0, 2).toUpperCase()} + + {a} + + ))} +
-
-

Sessions

-

{lt.total_sessions}

+
+ + {/* Stat row */} +
+ + + + + + 0} + sub={cronState.dead_letter_queue.length > 0 ? `${cronState.dead_letter_queue.length} dead-lettered task(s)` : undefined} + /> +
+ + {/* Context health + next phase */} +
+
+
+ context health + {scanAgeH !== null && scanAgeH > 6 && ( + stale — run openwolf scan + )} +
+
+
+ anatomy scanned + {scanAgeH === null ? "no scan state" : scanAgeH === 0 ? "under 1h ago" : `${scanAgeH}h ago`} +
+
+ git head pinned + {scanState.git_head ? scanState.git_head.slice(0, 7) : "—"} +
+
+ session digest budget + {config.context?.session_digest_budget_tokens ?? 1500} tok +
+
-
-

Tokens Saved

-

~{formatTokens(lt.estimated_savings_vs_bare_cli)}

- {lt.total_tokens_estimated > 0 && ( -

- {Math.round((lt.estimated_savings_vs_bare_cli / (lt.total_tokens_estimated + lt.estimated_savings_vs_bare_cli)) * 100)}% savings + +

+ next phase · status.md + {phase.length > 0 ? ( +
+ {phase.map((line, i) => ( +

{line}

+ ))} +
+ ) : ( +

+ No handoff yet — .wolf/STATUS.md fills in as work completes.

)}
- {/* Quick activity */} -
-

Recent Activity

- {memory.length === 0 ? ( -

No activity yet. Start a Claude Code session to see activity here.

+ {/* Weekly activity dot chart */} +
+
+ sessions / week + last {Math.min(tokenLedger.sessions.length, 60)} sessions +
+ {tokenLedger.sessions.length > 0 ? ( + ) : ( -
- {memory.slice(0, 3).flatMap((session) => - session.entries.slice(0, 5).map((entry, i) => ( -
- {entry.time} - {entry.action} - {entry.tokens} -
- )) - ).slice(0, 5)} -
+

No sessions yet — start your agent and this fills in live.

)}
diff --git a/src/dashboard/app/components/panels/TokenUsage.tsx b/src/dashboard/app/components/panels/TokenUsage.tsx index d1ce3816..cd3d07f2 100644 --- a/src/dashboard/app/components/panels/TokenUsage.tsx +++ b/src/dashboard/app/components/panels/TokenUsage.tsx @@ -1,92 +1,162 @@ import React from "react"; -import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Cell, Legend } from "recharts"; +import { AreaChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, ComposedChart } from "recharts"; import { formatTokens } from "../../lib/utils.js"; -import type { WolfData } from "../../hooks/useWolfData.js"; +import { StatTile } from "../shared/StatTile.js"; +import type { WolfData, LedgerSession } from "../../hooks/useWolfData.js"; + +function fmt(n: number | undefined): string { + return (n ?? 0).toLocaleString("en-US"); +} + +interface AgentRow { + agent: string; + sessions: number; + estimated: number; + realIn: number; + realOut: number; + cacheRead: number; +} + +function byAgent(sessions: LedgerSession[]): AgentRow[] { + const rows = new Map(); + for (const s of sessions) { + const agent = s.agent ?? "claude"; + const row = rows.get(agent) ?? { agent, sessions: 0, estimated: 0, realIn: 0, realOut: 0, cacheRead: 0 }; + row.sessions++; + row.estimated += (s.totals?.input_tokens_estimated ?? 0) + (s.totals?.output_tokens_estimated ?? 0); + if (s.real_usage) { + row.realIn += s.real_usage.input_tokens; + row.realOut += s.real_usage.output_tokens; + row.cacheRead += s.real_usage.cache_read_input_tokens; + } + rows.set(agent, row); + } + return [...rows.values()].sort((a, b) => b.estimated - a.estimated); +} export function TokenUsage({ data }: { data: WolfData }) { const { tokenLedger } = data; const lt = tokenLedger.lifetime; + const measured = (lt.real_api_calls ?? 0) > 0; - // Build chart data from sessions - const chartData = tokenLedger.sessions.map((s: any) => ({ - date: s.started?.slice(0, 10) || "", + const chartData = tokenLedger.sessions.map((s) => ({ + date: s.started?.slice(5, 10) || "", input: s.totals?.input_tokens_estimated || 0, output: s.totals?.output_tokens_estimated || 0, + measured: s.real_usage ? s.real_usage.input_tokens + s.real_usage.output_tokens : null, })); - // Comparison data - const totalTracked = lt.total_tokens_estimated; - const savings = lt.estimated_savings_vs_bare_cli; - const withoutWolf = totalTracked + savings; - // OpenClaw adds overhead — uses ~30% MORE tokens than bare CLI due to extra turns/sessions - const withOpenClaw = Math.round(withoutWolf * 1.3); - const savingsPercent = withoutWolf > 0 ? Math.round((savings / withoutWolf) * 100) : 0; - - const comparisonData = [ - { name: "OpenClaw + Claude", tokens: withOpenClaw, fill: "#f87171" }, - { name: "Claude CLI (without OpenWolf)", tokens: withoutWolf, fill: "#fbbf24" }, - { name: "OpenWolf + Claude CLI", tokens: totalTracked, fill: "#34d399" }, - ]; + const agents = byAgent(tokenLedger.sessions); + const savingsPct = lt.total_tokens_estimated > 0 + ? Math.round((lt.estimated_savings_vs_bare_cli / (lt.total_tokens_estimated + lt.estimated_savings_vs_bare_cli)) * 100) + : 0; return ( -
+
+ {/* Headline tiles */} +
+ + + + 0 ? formatTokens(lt.estimated_savings_vs_bare_cli) : "0"} + sub={savingsPct > 0 ? `${savingsPct}% of would-be usage` : "approximate by design"} + variant="inverted" + size="md" + /> +
+ {/* Usage over time */} -
-

Usage Over Time

+
+
+ usage over time · per session + tokens +
{chartData.length === 0 ? (
No session data yet.
) : ( - - - - - formatTokens(v)} /> - - - - + + + + + formatTokens(v)} axisLine={false} tickLine={false} /> + [formatTokens(v), name]} + /> + + + + {measured && ( + + )} + )}
- {/* Comparison */} -
-
-

Token Comparison

- {savingsPercent > 0 && ( - - OpenWolf saved ~{savingsPercent}% - - )} -
- - - formatTokens(v)} /> - - [formatTokens(v) + " tokens", ""]} /> - - {comparisonData.map((entry, i) => ( - - ))} - - - -

Estimates based on project size and average session patterns.

+ {/* Per-agent breakdown */} +
+ by agent + {agents.length === 0 ? ( +

No sessions recorded yet.

+ ) : ( +
+ + + + + + + + + + + + + {agents.map((row) => ( + + + + + + + + + ))} + +
agentsessionsestimatedmeasured inmeasured outcache read
{row.agent}{fmt(row.sessions)}{formatTokens(row.estimated)}{row.realIn > 0 ? formatTokens(row.realIn) : "—"}{row.realOut > 0 ? formatTokens(row.realOut) : "—"}{row.cacheRead > 0 ? formatTokens(row.cacheRead) : "—"}
+
+ )} +

+ estimated = char-ratio heuristic · measured = summed from harness transcripts at session end +

{/* Waste alerts */} {tokenLedger.waste_flags.length > 0 && ( -
-

Waste Alerts

-
+
+ waste alerts +
{tokenLedger.waste_flags.map((flag: any, i: number) => ( -
-
- +
+
+
-

{flag.pattern}

+

{flag.pattern}

{flag.description}

-

{flag.suggestion}

+

{flag.suggestion}

diff --git a/src/dashboard/app/components/shared/DotBar.tsx b/src/dashboard/app/components/shared/DotBar.tsx new file mode 100644 index 00000000..e27f8613 --- /dev/null +++ b/src/dashboard/app/components/shared/DotBar.tsx @@ -0,0 +1,71 @@ +import React, { useState } from "react"; + +export interface DotBarDatum { + label: string; + value: number; + highlight?: boolean; // red column — reserved for "current" / over-limit +} + +interface DotBarProps { + data: DotBarDatum[]; + rows?: number; // dot resolution per column + unit?: string; // tooltip unit +} + +/** + * Dot-matrix column chart: each column is a stack of dots filled bottom-up in + * proportion to its value. Hover shows an exact-value tooltip (charts are + * interactive by default; identity is carried by the label, not color). + */ +export function DotBar({ data, rows = 7, unit = "" }: DotBarProps) { + const [hover, setHover] = useState(null); + const max = Math.max(1, ...data.map((d) => d.value)); + + return ( +
+
+ {data.map((d, i) => { + const filled = d.value <= 0 ? 0 : Math.max(1, Math.round((d.value / max) * rows)); + return ( +
setHover(i)} + onMouseLeave={() => setHover(null)} + > + {Array.from({ length: rows }).map((_, r) => ( + + ))} + + {d.label} + +
+ ); + })} +
+ {hover !== null && data[hover] && ( +
+ {data[hover].label}: {data[hover].value.toLocaleString()}{unit ? ` ${unit}` : ""} +
+ )} +
+ ); +} diff --git a/src/dashboard/app/components/shared/LiveIndicator.tsx b/src/dashboard/app/components/shared/LiveIndicator.tsx index 601ef508..d5171dc7 100644 --- a/src/dashboard/app/components/shared/LiveIndicator.tsx +++ b/src/dashboard/app/components/shared/LiveIndicator.tsx @@ -2,9 +2,9 @@ import React from "react"; export function LiveIndicator() { return ( - - - Live + + + live ); } diff --git a/src/dashboard/app/components/shared/StatTile.tsx b/src/dashboard/app/components/shared/StatTile.tsx new file mode 100644 index 00000000..31a96be3 --- /dev/null +++ b/src/dashboard/app/components/shared/StatTile.tsx @@ -0,0 +1,36 @@ +import React from "react"; + +interface StatTileProps { + label: string; + value: string; + sub?: string; + variant?: "default" | "inverted" | "outline"; + accent?: boolean; // red value — reserved for attention + size?: "md" | "lg" | "xl"; + corner?: React.ReactNode; // top-right slot (pill, dot, toggle) + children?: React.ReactNode; +} + +const sizeMap = { md: "text-3xl", lg: "text-5xl", xl: "text-6xl" }; + +export function StatTile({ label, value, sub, variant = "default", accent, size = "lg", corner, children }: StatTileProps) { + const cardClass = variant === "inverted" ? "wd-card-inverted" : "wd-card"; + const mutedColor = variant === "inverted" ? "color-mix(in srgb, var(--invert-text) 55%, transparent)" : "var(--text-muted)"; + return ( +
+
+ {label} + {corner} +
+
+
+ {value} +
+ {sub &&

{sub}

} + {children} +
+
+ ); +} diff --git a/src/dashboard/app/components/shared/StatusBadge.tsx b/src/dashboard/app/components/shared/StatusBadge.tsx index c3cfe07b..2412f49c 100644 --- a/src/dashboard/app/components/shared/StatusBadge.tsx +++ b/src/dashboard/app/components/shared/StatusBadge.tsx @@ -1,31 +1,39 @@ import React from "react"; import { cn } from "../../lib/utils.js"; -const variants: Record = { - healthy: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30", - running: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30", - success: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30", - ok: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30", - enabled: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30", - warning: "bg-amber-500/20 text-amber-400 border-amber-500/30", - retrying: "bg-amber-500/20 text-amber-400 border-amber-500/30", - degraded: "bg-amber-500/20 text-amber-400 border-amber-500/30", - error: "bg-red-500/20 text-red-400 border-red-500/30", - failed: "bg-red-500/20 text-red-400 border-red-500/30", - stopped: "bg-red-500/20 text-red-400 border-red-500/30", - disabled: "bg-zinc-500/20 text-zinc-400 border-zinc-500/30", - unknown: "bg-zinc-500/20 text-zinc-400 border-zinc-500/30", - initialized: "bg-blue-500/20 text-blue-400 border-blue-500/30", +// Monochrome status system: ok = neutral ink dot, warning = outlined red dot, +// error = filled red. Status is never carried by color alone — the label +// always names the state. +type Tone = "ok" | "warn" | "bad" | "off"; + +const toneOf: Record = { + healthy: "ok", running: "ok", success: "ok", ok: "ok", enabled: "ok", initialized: "ok", + warning: "warn", retrying: "warn", degraded: "warn", + error: "bad", failed: "bad", stopped: "bad", + disabled: "off", unknown: "off", }; -export function StatusBadge({ status, className }: { status: string; className?: string }) { - const variant = variants[status.toLowerCase()] || variants.unknown; +export function StatusBadge({ status, className }: { status?: string | null; className?: string }) { + // Never trust the incoming value: a failed/malformed API response can leave + // this undefined, and a crash here white-screens the whole dashboard. + const label = typeof status === "string" && status.trim() ? status : "unknown"; + const tone = toneOf[label.toLowerCase()] ?? "off"; + const dotStyle: React.CSSProperties = + tone === "ok" ? { background: "var(--ok)" } + : tone === "warn" ? { background: "transparent", border: "1.5px solid var(--accent)" } + : tone === "bad" ? { background: "var(--accent)" } + : { background: "var(--text-faint)" }; return ( - - {status === "running" || status === "healthy" ? ( - - ) : null} - {status.charAt(0).toUpperCase() + status.slice(1)} + + + {label} ); } diff --git a/src/dashboard/app/components/shared/TokenBadge.tsx b/src/dashboard/app/components/shared/TokenBadge.tsx index 7b0cf2bc..a8a3eb89 100644 --- a/src/dashboard/app/components/shared/TokenBadge.tsx +++ b/src/dashboard/app/components/shared/TokenBadge.tsx @@ -2,9 +2,10 @@ import React from "react"; import { cn, formatTokens } from "../../lib/utils.js"; export function TokenBadge({ tokens, className }: { tokens: number; className?: string }) { - const color = tokens < 200 ? "text-emerald-400" : tokens < 1000 ? "text-amber-400" : "text-red-400"; + // Neutral ink for normal sizes; red is reserved for genuinely heavy reads. + const style = { color: tokens < 1000 ? "var(--text-muted)" : "var(--accent)" }; return ( - + ~{formatTokens(tokens)} tok ); diff --git a/src/dashboard/app/hooks/useWolfData.ts b/src/dashboard/app/hooks/useWolfData.ts index f982c52d..4ed0e8b3 100644 --- a/src/dashboard/app/hooks/useWolfData.ts +++ b/src/dashboard/app/hooks/useWolfData.ts @@ -1,8 +1,32 @@ import { useState, useEffect, useCallback } from "react"; -import { WolfClient } from "../lib/wolf-client.js"; +import { dashboardFetch, WolfClient } from "../lib/wolf-client.js"; import { parseAnatomy, parseMemory, parseCerebrum } from "../lib/file-parsers.js"; import type { AnatomyEntry, MemorySession, CerebrumData } from "../lib/file-parsers.js"; +export interface RealUsage { + input_tokens: number; + output_tokens: number; + cache_read_input_tokens: number; + cache_creation_input_tokens: number; + api_calls: number; +} + +export interface LedgerSession { + id: string; + agent?: string; + started: string; + ended: string; + totals: { + input_tokens_estimated: number; + output_tokens_estimated: number; + reads_count: number; + writes_count: number; + repeated_reads_blocked: number; + anatomy_lookups: number; + }; + real_usage?: RealUsage; +} + interface TokenLedger { lifetime: { total_tokens_estimated: number; @@ -13,11 +37,27 @@ interface TokenLedger { anatomy_misses: number; repeated_reads_blocked: number; estimated_savings_vs_bare_cli: number; + real_input_tokens?: number; + real_output_tokens?: number; + real_cache_read_tokens?: number; + real_cache_creation_tokens?: number; + real_api_calls?: number; }; - sessions: any[]; + sessions: LedgerSession[]; waste_flags: any[]; } +export interface WolfConfig { + agents: string[]; + context?: { session_digest_budget_tokens?: number; budgets?: Record }; +} + +export interface ScanState { + last_scanned?: string; + git_head?: string | null; + file_count?: number; +} + interface CronState { engine_status: string; last_heartbeat: string | null; @@ -33,13 +73,6 @@ interface CronManifest { tasks: any[]; } -interface DesignQCReport { - captured_at: string | null; - captures: any[]; - total_size_kb: number; - estimated_tokens: number; -} - interface Health { status: string; uptime_seconds: number; @@ -60,11 +93,14 @@ export interface WolfData { cronManifest: CronManifest; buglog: BugLog; suggestions: any; - designqcReport: DesignQCReport | null; health: Health; identity: { name: string; role: string }; project: ProjectMeta; + config: WolfConfig; + statusDoc: string; + scanState: ScanState; loading: boolean; + authError: boolean; client: WolfClient | null; } @@ -78,14 +114,37 @@ export function useWolfData(): WolfData { const [cronManifest, setCronManifest] = useState({ tasks: [] }); const [buglog, setBuglog] = useState({ bugs: [] }); const [suggestions, setSuggestions] = useState(null); - const [designqcReport, setDesignqcReport] = useState(null); const [health, setHealth] = useState({ status: "unknown", uptime_seconds: 0 }); const [identity, setIdentity] = useState({ name: "Wolf", role: "AI development assistant" }); const [project, setProject] = useState({ name: "", description: "", root: "" }); + const [config, setConfig] = useState({ agents: ["claude"] }); + const [statusDoc, setStatusDoc] = useState(""); + const [scanState, setScanState] = useState({}); const [client, setClient] = useState(null); + const [authError, setAuthError] = useState(false); const processFiles = useCallback((files: Record) => { - if (files["anatomy.md"]) setAnatomy(parseAnatomy(files["anatomy.md"])); + if (files["anatomy-index.json"]) { + try { + const store = JSON.parse(files["anatomy-index.json"]); + const entries = Object.entries(store.files ?? {}).map(([relPath, e]: [string, any]) => { + const slash = relPath.lastIndexOf("/"); + return { + file: slash === -1 ? relPath : relPath.slice(slash + 1), + description: e.description ?? "", + tokens: e.tokens ?? 0, + section: slash === -1 ? "./" : relPath.slice(0, slash + 1), + symbols: e.symbols, + }; + }); + setAnatomy({ + entries, + metadata: { files: entries.length, hits: store.meta?.hits ?? 0, misses: store.meta?.misses ?? 0 }, + }); + } catch { + if (files["anatomy.md"]) setAnatomy(parseAnatomy(files["anatomy.md"])); + } + } else if (files["anatomy.md"]) setAnatomy(parseAnatomy(files["anatomy.md"])); if (files["cerebrum.md"]) setCerebrum(parseCerebrum(files["cerebrum.md"])); if (files["memory.md"]) setMemory(parseMemory(files["memory.md"])); if (files["token-ledger.json"]) { @@ -103,8 +162,15 @@ export function useWolfData(): WolfData { if (files["suggestions.json"]) { try { setSuggestions(JSON.parse(files["suggestions.json"])); } catch {} } - if (files["designqc-report.json"]) { - try { setDesignqcReport(JSON.parse(files["designqc-report.json"])); } catch {} + if (files["config.json"]) { + try { + const cfg = JSON.parse(files["config.json"]); + setConfig({ agents: cfg?.openwolf?.agents ?? ["claude"], context: cfg?.openwolf?.context }); + } catch {} + } + if (files["STATUS.md"] !== undefined && files["STATUS.md"] !== "") setStatusDoc(files["STATUS.md"]); + if (files["_scan-state.json"]) { + try { setScanState(JSON.parse(files["_scan-state.json"])); } catch {} } if (files["identity.md"]) { const nameMatch = files["identity.md"].match(/\*\*Name:\*\*\s*(.+)/); @@ -119,23 +185,28 @@ export function useWolfData(): WolfData { }, []); useEffect(() => { - // Initial fetch - fetch("/api/files") - .then(r => r.json()) + // Initial fetch. Never feed a non-OK response body into state: a 401 error + // object like {error:"..."} would overwrite defaults and crash the UI. + dashboardFetch("/api/files") + .then(r => { + if (r.status === 401) { setAuthError(true); throw new Error("unauthorized"); } + if (!r.ok) throw new Error(String(r.status)); + return r.json(); + }) .then(files => { processFiles(files); setLoading(false); }) .catch(() => setLoading(false)); - fetch("/api/health") - .then(r => r.json()) - .then(h => setHealth(h)) + dashboardFetch("/api/health") + .then(r => (r.ok ? r.json() : null)) + .then(h => { if (h && typeof h.status === "string") setHealth(h); }) .catch(() => {}); - fetch("/api/project") - .then(r => r.json()) - .then(p => setProject(p)) + dashboardFetch("/api/project") + .then(r => (r.ok ? r.json() : null)) + .then(p => { if (p && typeof p.name === "string") setProject(p); }) .catch(() => {}); // WebSocket @@ -158,5 +229,5 @@ export function useWolfData(): WolfData { return () => wsClient.disconnect(); }, [processFiles]); - return { anatomy, cerebrum, memory, tokenLedger, cronState, cronManifest, buglog, suggestions, designqcReport, health, identity, project, loading, client }; + return { anatomy, cerebrum, memory, tokenLedger, cronState, cronManifest, buglog, suggestions, health, identity, project, config, statusDoc, scanState, loading, authError, client }; } diff --git a/src/dashboard/app/index.html b/src/dashboard/app/index.html index 3bc4db9f..67886c8d 100644 --- a/src/dashboard/app/index.html +++ b/src/dashboard/app/index.html @@ -6,7 +6,7 @@ OpenWolf Dashboard - +
diff --git a/src/dashboard/app/lib/file-parsers.ts b/src/dashboard/app/lib/file-parsers.ts index 74131c2d..bf862920 100644 --- a/src/dashboard/app/lib/file-parsers.ts +++ b/src/dashboard/app/lib/file-parsers.ts @@ -1,4 +1,13 @@ +export interface AnatomySymbol { + name: string; + kind: string; + startLine: number; + endLine: number; + tokens: number; +} + export interface AnatomyEntry { + symbols?: AnatomySymbol[]; file: string; description: string; tokens: number; @@ -24,7 +33,8 @@ export function parseAnatomy(content: string): { entries: AnatomyEntry[]; metada let currentSection = ""; let files = 0, hits = 0, misses = 0; - for (const line of content.split("\n")) { + for (const raw of content.split("\n")) { + const line = raw.replace(/\r$/, ""); const metaMatch = line.match(/Files:\s*(\d+).*hits:\s*(\d+).*Misses:\s*(\d+)/i); if (metaMatch) { files = parseInt(metaMatch[1]); diff --git a/src/dashboard/app/lib/wolf-client.ts b/src/dashboard/app/lib/wolf-client.ts index a78c616b..3fa44746 100644 --- a/src/dashboard/app/lib/wolf-client.ts +++ b/src/dashboard/app/lib/wolf-client.ts @@ -1,5 +1,25 @@ type MessageHandler = (msg: any) => void; +export function getDashboardToken(): string { + const params = new URLSearchParams(location.search); + const token = params.get("token"); + if (token) { + sessionStorage.setItem("openwolf-dashboard-token", token); + params.delete("token"); + const query = params.toString(); + history.replaceState(null, "", `${location.pathname}${query ? `?${query}` : ""}${location.hash}`); + return token; + } + return sessionStorage.getItem("openwolf-dashboard-token") || ""; +} + +export function dashboardFetch(path: string, init?: RequestInit): Promise { + return fetch(path, { + ...init, + headers: { ...(init?.headers ?? {}), Authorization: `Bearer ${getDashboardToken()}` }, + }); +} + export class WolfClient { private ws: WebSocket | null = null; private handlers: MessageHandler[] = []; @@ -8,7 +28,8 @@ export class WolfClient { constructor(url?: string) { const wsProtocol = location.protocol === "https:" ? "wss:" : "ws:"; - this.url = url || `${wsProtocol}//${location.host}/ws`; + const token = encodeURIComponent(getDashboardToken()); + this.url = url || `${wsProtocol}//${location.host}/ws?token=${token}`; } connect(): void { diff --git a/src/dashboard/app/styles/globals.css b/src/dashboard/app/styles/globals.css index eb5226ae..2a9abf4b 100644 --- a/src/dashboard/app/styles/globals.css +++ b/src/dashboard/app/styles/globals.css @@ -1,57 +1,81 @@ @import "tailwindcss"; +/* ───────────────────────────────────────────────────────────────────────── + OpenWolf Dashboard 2.0 — dot-matrix design system + Monochrome surfaces, one signal-red accent, dot-display numerals. + Red is reserved for live/attention/over-limit — never decoration. + ───────────────────────────────────────────────────────────────────────── */ + :root { - --font-sans: 'Inter', system-ui, -apple-system, sans-serif; - --font-mono: 'JetBrains Mono', monospace; + --font-sans: "Space Grotesk", system-ui, -apple-system, sans-serif; + --font-mono: "Space Mono", "JetBrains Mono", monospace; + --font-dot: "Doto", "Space Mono", monospace; - /* Theme variables — light mode defaults */ - --bg-base: #ffffff; - --bg-surface: #f4f4f5; - --bg-surface-hover: #e4e4e7; + /* Light theme */ + --bg-base: #e9e9e6; + --bg-surface: #f6f6f4; + --bg-surface-hover: #ededea; --bg-elevated: #ffffff; - --border: #d4d4d8; - --border-subtle: #e4e4e7; - --text-primary: #09090b; - --text-secondary: #3f3f46; - --text-muted: #71717a; - --text-faint: #a1a1aa; - --accent: #059669; - --accent-subtle: rgba(5, 150, 105, 0.1); - --danger: #dc2626; - --danger-subtle: rgba(220, 38, 38, 0.05); - --warning: #d97706; - --warning-subtle: rgba(217, 119, 6, 0.05); - --scrollbar-track: #f4f4f5; - --scrollbar-thumb: #d4d4d8; - --scrollbar-thumb-hover: #a1a1aa; + --border: #cfcfca; + --border-subtle: #dededa; + --text-primary: #101010; + --text-secondary: #3c3c3a; + --text-muted: #6f6f6c; + --text-faint: #9d9d99; + --accent: #d71921; /* signal red */ + --accent-subtle: rgba(215, 25, 33, 0.08); + --danger: #d71921; + --danger-subtle: rgba(215, 25, 33, 0.06); + --warning: #8a6d00; + --warning-subtle: rgba(138, 109, 0, 0.08); + --ok: #101010; /* status-ok is neutral ink, not green */ + --invert-bg: #111110; /* the single inverted tile */ + --invert-text: #f4f3ef; + --scrollbar-track: #e9e9e6; + --scrollbar-thumb: #cfcfca; + --scrollbar-thumb-hover: #9d9d99; --chart-tooltip-bg: #ffffff; - --chart-tooltip-border: #d4d4d8; - --chart-grid: #e4e4e7; + --chart-tooltip-border: #cfcfca; + --chart-grid: #dededa; + /* chart series: lightness-separated monochrome; red = highlight only */ + --series-1: #101010; + --series-2: #6f6f6c; + --series-3: #b3b3af; + --series-red: #d71921; + --dot-off: #d8d8d4; } [data-theme="dark"] { - --bg-base: #09090b; - --bg-surface: #18181b; - --bg-surface-hover: #27272a; - --bg-elevated: #18181b; - --border: #27272a; - --border-subtle: #3f3f46; - --text-primary: #fafafa; - --text-secondary: #a1a1aa; - --text-muted: #71717a; - --text-faint: #52525b; - --accent: #34d399; - --accent-subtle: rgba(52, 211, 153, 0.1); - --danger: #f87171; - --danger-subtle: rgba(248, 113, 113, 0.05); - --warning: #fbbf24; - --warning-subtle: rgba(251, 191, 36, 0.05); - --scrollbar-track: #18181b; - --scrollbar-thumb: #3f3f46; - --scrollbar-thumb-hover: #52525b; - --chart-tooltip-bg: #18181b; - --chart-tooltip-border: #3f3f46; - --chart-grid: #27272a; + --bg-base: #0a0a0a; + --bg-surface: #151514; + --bg-surface-hover: #1e1e1d; + --bg-elevated: #191918; + --border: #2a2a28; + --border-subtle: #202020; + --text-primary: #f2f1ed; + --text-secondary: #b9b8b3; + --text-muted: #7d7c78; + --text-faint: #4e4e4b; + --accent: #ff4438; + --accent-subtle: rgba(255, 68, 56, 0.1); + --danger: #ff4438; + --danger-subtle: rgba(255, 68, 56, 0.08); + --warning: #e0b429; + --warning-subtle: rgba(224, 180, 41, 0.08); + --ok: #f2f1ed; + --invert-bg: #eceae2; /* pale card floating on black */ + --invert-text: #121210; + --scrollbar-track: #0a0a0a; + --scrollbar-thumb: #2a2a28; + --scrollbar-thumb-hover: #4e4e4b; + --chart-tooltip-bg: #191918; + --chart-tooltip-border: #2a2a28; + --chart-grid: #202020; + --series-1: #f2f1ed; + --series-2: #9c9b96; + --series-3: #55554f; + --series-red: #ff4438; + --dot-off: #242422; } body { @@ -67,35 +91,60 @@ code, pre, .font-mono { font-family: var(--font-mono); } -* { - box-sizing: border-box; -} +* { box-sizing: border-box; } -::-webkit-scrollbar { - width: 8px; +/* Display numerals — the dot-matrix signature */ +.dot-display { + font-family: var(--font-dot); + font-weight: 900; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; + line-height: 0.95; } -::-webkit-scrollbar-track { - background: var(--scrollbar-track); +/* Small uppercase mono labels — widget captions */ +.wd-label { + font-family: var(--font-mono); + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.14em; } -::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb); - border-radius: 4px; +/* Card primitives */ +.wd-card { + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 20px; } - -::-webkit-scrollbar-thumb:hover { - background: var(--scrollbar-thumb-hover); +.wd-card-inverted { + background: var(--invert-bg); + color: var(--invert-text); + border: 1px solid transparent; + border-radius: 20px; +} +.wd-pill { + border-radius: 999px; + border: 1px solid var(--border); + background: transparent; + font-family: var(--font-mono); + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.1em; } -@keyframes pulse-green { +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: var(--scrollbar-track); } +::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); } + +@keyframes rec-pulse { 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } + 50% { opacity: 0.35; } } +.rec-pulse { animation: rec-pulse 1.6s ease-in-out infinite; } -.pulse-green { - animation: pulse-green 2s ease-in-out infinite; -} +/* legacy alias kept for panels not yet migrated */ +.pulse-green { animation: rec-pulse 2s ease-in-out infinite; } /* Theme-aware utility classes */ .bg-base { background-color: var(--bg-base); } diff --git a/src/designqc/designqc-capture.ts b/src/designqc/designqc-capture.ts deleted file mode 100644 index 898a8986..00000000 --- a/src/designqc/designqc-capture.ts +++ /dev/null @@ -1,256 +0,0 @@ -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as http from "node:http"; -import { execSync, spawn, type ChildProcess } from "node:child_process"; -import type { Viewport, Screenshot } from "./designqc-types.js"; - -export function findChromePath(configPath?: string | null): string { - if (configPath && fs.existsSync(configPath)) return configPath; - - if (process.platform === "win32") { - const candidates = [ - path.join(process.env["PROGRAMFILES"] || "C:\\Program Files", "Google\\Chrome\\Application\\chrome.exe"), - path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Google\\Chrome\\Application\\chrome.exe"), - path.join(process.env["LOCALAPPDATA"] || "", "Google\\Chrome\\Application\\chrome.exe"), - path.join(process.env["PROGRAMFILES(X86)"] || "C:\\Program Files (x86)", "Microsoft\\Edge\\Application\\msedge.exe"), - path.join(process.env["PROGRAMFILES"] || "C:\\Program Files", "Microsoft\\Edge\\Application\\msedge.exe"), - ]; - for (const c of candidates) { - if (fs.existsSync(c)) return c; - } - try { - const r = execSync("where chrome", { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }).trim(); - if (r) return r.split("\n")[0].trim(); - } catch {} - try { - const r = execSync("where msedge", { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }).trim(); - if (r) return r.split("\n")[0].trim(); - } catch {} - } else if (process.platform === "darwin") { - for (const c of [ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Chromium.app/Contents/MacOS/Chromium", - ]) { - if (fs.existsSync(c)) return c; - } - } else { - try { - return execSync("which google-chrome || which chromium || which chromium-browser", { - encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"], - }).trim().split("\n")[0]; - } catch {} - } - - throw new Error("Chrome/Edge not found. Install Chrome or set designqc.chrome_path in .wolf/config.json"); -} - -/** - * Capture a full page as sectioned viewport-height screenshots. - * Returns multiple screenshots — one per "fold" of the page. - * This gives Claude focused views of each section without one massive image. - */ -export async function captureRouteSectioned( - page: import("puppeteer-core").Page, - url: string, - viewport: Viewport, - outputDir: string, - quality: number, - maxWidth: number, -): Promise { - const scale = maxWidth < viewport.width ? maxWidth / viewport.width : 1; - const captureWidth = Math.round(viewport.width * scale); - const captureHeight = Math.round(viewport.height * scale); - - await page.setViewport({ width: captureWidth, height: captureHeight }); - await page.goto(url, { waitUntil: "networkidle2", timeout: 30_000 }); - await new Promise((r) => setTimeout(r, 1500)); - - // Get full page height - const fullHeight = await page.evaluate(() => document.documentElement.scrollHeight); - const route = new URL(url).pathname; - const safeName = route.replace(/\//g, "_").replace(/^_/, "") || "root"; - - const screenshots: Screenshot[] = []; - const sectionHeight = captureHeight; - const totalSections = Math.ceil(fullHeight / sectionHeight); - // Cap at 8 sections (~20K tokens) to avoid runaway costs - const maxSections = Math.min(totalSections, 8); - - for (let i = 0; i < maxSections; i++) { - const y = i * sectionHeight; - - // Scroll to position - await page.evaluate((scrollY: number) => window.scrollTo(0, scrollY), y); - await new Promise((r) => setTimeout(r, 500)); - - const screenshotBuffer = await page.screenshot({ - fullPage: false, - type: "jpeg", - quality, - }); - - const sectionLabel = i === 0 ? "top" : i === maxSections - 1 ? "bottom" : `section${i + 1}`; - const fileName = `${safeName}_${viewport.name}_${sectionLabel}.jpg`; - const filePath = path.join(outputDir, fileName); - - fs.writeFileSync(filePath, screenshotBuffer); - screenshots.push({ route, viewport, path: filePath }); - } - - return screenshots; -} - -export function detectRoutes(projectRoot: string): string[] { - const routes: string[] = ["/"]; - - const dirs = [ - path.join(projectRoot, "pages"), - path.join(projectRoot, "app"), - path.join(projectRoot, "src", "pages"), - path.join(projectRoot, "src", "app"), - ].filter((d) => fs.existsSync(d)); - - for (const dir of dirs) { - try { - const files = fs.readdirSync(dir, { recursive: true }) as string[]; - for (const file of files) { - const f = String(file).replace(/\\/g, "/"); - if (f.includes("api/") || f.includes("_") || f.includes("layout.")) continue; - if (f.endsWith(".tsx") || f.endsWith(".jsx") || f.endsWith(".ts") || f.endsWith(".js")) { - let route = "/" + f - .replace(/\.(tsx|jsx|ts|js)$/, "") - .replace(/\/index$/, "") - .replace(/\/page$/, ""); - if (route === "/") continue; - routes.push(route); - } - } - } catch {} - } - - return [...new Set(routes)].slice(0, 10); -} - -export async function probePort(port: number): Promise { - return new Promise((resolve) => { - const req = http.get(`http://localhost:${port}`, () => resolve(true)); - req.on("error", () => resolve(false)); - req.setTimeout(2000, () => { req.destroy(); resolve(false); }); - }); -} - -/** - * Try to find a running dev server on common ports. - */ -export async function detectDevServer(): Promise<{ url: string; port: number } | null> { - const commonPorts = [3000, 3001, 5173, 5174, 4321, 8080, 8000, 4200]; - for (const port of commonPorts) { - if (await probePort(port)) { - return { url: `http://localhost:${port}`, port }; - } - } - return null; -} - -/** - * Detect the dev command from package.json. - * Returns { command, port } or null. - */ -export function detectDevCommand(projectRoot: string): { command: string; expectedPort: number } | null { - const pkgPath = path.join(projectRoot, "package.json"); - if (!fs.existsSync(pkgPath)) return null; - - try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); - const scripts = pkg.scripts || {}; - - // Priority order: dev, start, serve - for (const key of ["dev", "start", "serve"]) { - if (scripts[key]) { - // Try to detect port from the script - const portMatch = scripts[key].match(/-p\s+(\d+)|--port\s+(\d+)|PORT=(\d+)/); - let port = 3000; - if (portMatch) { - port = parseInt(portMatch[1] || portMatch[2] || portMatch[3], 10); - } else if (scripts[key].includes("vite")) { - port = 5173; - } else if (scripts[key].includes("next")) { - port = 3000; - } else if (scripts[key].includes("astro")) { - port = 4321; - } else if (scripts[key].includes("angular") || scripts[key].includes("ng serve")) { - port = 4200; - } - - // Determine package manager - let runner = "npm run"; - if (fs.existsSync(path.join(projectRoot, "pnpm-lock.yaml"))) runner = "pnpm"; - else if (fs.existsSync(path.join(projectRoot, "yarn.lock"))) runner = "yarn"; - else if (fs.existsSync(path.join(projectRoot, "bun.lockb"))) runner = "bun run"; - - return { command: `${runner} ${key}`, expectedPort: port }; - } - } - } catch {} - - return null; -} - -/** - * Start the dev server, wait for it to be ready, return the process handle. - * Caller is responsible for killing the process. - */ -export async function startDevServer( - projectRoot: string, -): Promise<{ proc: ChildProcess; url: string; port: number } | null> { - const devCmd = detectDevCommand(projectRoot); - if (!devCmd) { - console.error(" No dev script found in package.json (looked for: dev, start, serve)"); - return null; - } - - console.log(` Starting dev server: ${devCmd.command}`); - - const proc = spawn(devCmd.command, { - cwd: projectRoot, - shell: true, - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - - // Wait for server to be ready (poll port) - const port = devCmd.expectedPort; - const maxWait = 30_000; - const start = Date.now(); - let ready = false; - - while (Date.now() - start < maxWait) { - // Check if process died - if (proc.exitCode !== null) { - console.error(` Dev server exited with code ${proc.exitCode}`); - return null; - } - - if (await probePort(port)) { - ready = true; - break; - } - - await new Promise((r) => setTimeout(r, 1000)); - } - - if (!ready) { - // Try nearby ports in case the detected port was wrong - for (const p of [3000, 3001, 5173, 5174, 4321, 8080]) { - if (p !== port && await probePort(p)) { - console.log(` Server responded on port ${p} (expected ${port})`); - return { proc, url: `http://localhost:${p}`, port: p }; - } - } - console.error(` Dev server did not respond on port ${port} within ${maxWait / 1000}s`); - proc.kill(); - return null; - } - - return { proc, url: `http://localhost:${port}`, port }; -} diff --git a/src/designqc/designqc-engine.ts b/src/designqc/designqc-engine.ts deleted file mode 100644 index 1e89cabf..00000000 --- a/src/designqc/designqc-engine.ts +++ /dev/null @@ -1,158 +0,0 @@ -import * as path from "node:path"; -import * as fs from "node:fs"; -import { type ChildProcess } from "node:child_process"; -import { appendText } from "../utils/fs-safe.js"; -import { - findChromePath, - captureRouteSectioned, - detectRoutes, - detectDevServer, - startDevServer, -} from "./designqc-capture.js"; -import type { DesignQCOptions, Screenshot, CaptureResult } from "./designqc-types.js"; -import { DEFAULT_VIEWPORTS } from "./designqc-types.js"; - -export class DesignQCEngine { - private wolfDir: string; - private projectRoot: string; - private options: DesignQCOptions; - - constructor(wolfDir: string, projectRoot: string, options: DesignQCOptions) { - this.wolfDir = wolfDir; - this.projectRoot = projectRoot; - this.options = options; - } - - async capture(): Promise { - let baseUrl = this.options.devServerUrl; - let serverProc: ChildProcess | null = null; - let weStartedServer = false; - - try { - // 1. Find or start dev server - if (!baseUrl) { - console.log(" Checking for running dev server..."); - const existing = await detectDevServer(); - if (existing) { - baseUrl = existing.url; - console.log(` Found running server at ${baseUrl}`); - } else { - console.log(" No running server found. Starting dev server..."); - const started = await startDevServer(this.projectRoot); - if (!started) { - console.error(" Could not start dev server. Use --url to specify manually."); - return { screenshots: [], captureDir: "", totalSizeKB: 0, estimatedTokens: 0 }; - } - baseUrl = started.url; - serverProc = started.proc; - weStartedServer = true; - console.log(` Dev server ready at ${baseUrl}`); - } - } - - // 2. Detect routes - let routes = this.options.routes || []; - if (routes.length === 0) { - routes = detectRoutes(this.projectRoot); - } - console.log(` Routes: ${routes.join(", ")}`); - - // 3. Prepare output directory - const captureDir = path.join(this.wolfDir, "designqc-captures"); - if (fs.existsSync(captureDir)) { - for (const f of fs.readdirSync(captureDir)) { - fs.unlinkSync(path.join(captureDir, f)); - } - } else { - fs.mkdirSync(captureDir, { recursive: true }); - } - - // 4. Launch browser - console.log(" Launching browser..."); - const chromePath = findChromePath(this.options.chromePath); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let puppeteer: any; - try { - puppeteer = await import("puppeteer-core"); - } catch { - console.error("puppeteer-core is required for designqc. Install it with: pnpm add puppeteer-core"); - return { screenshots: [], captureDir: "", totalSizeKB: 0, estimatedTokens: 0 }; - } - const browser = await puppeteer.default.launch({ - executablePath: chromePath, - headless: true, - args: ["--no-sandbox", "--disable-setuid-sandbox"], - }); - - const viewports = this.options.viewports || DEFAULT_VIEWPORTS; - const allScreenshots: Screenshot[] = []; - - try { - const page = await browser.newPage(); - - for (const route of routes) { - for (const viewport of viewports) { - if (allScreenshots.length >= this.options.maxScreenshots) break; - const url = `${baseUrl}${route}`; - console.log(` Capturing ${route} (${viewport.name}) — full page sections...`); - try { - const sections = await captureRouteSectioned( - page, url, viewport, captureDir, - this.options.quality, this.options.maxWidth, - ); - // Respect max screenshots limit - const remaining = this.options.maxScreenshots - allScreenshots.length; - allScreenshots.push(...sections.slice(0, remaining)); - } catch (err) { - console.error(` Failed to capture ${url}: ${(err as Error).message}`); - } - } - if (allScreenshots.length >= this.options.maxScreenshots) break; - } - - await page.close(); - } finally { - await browser.close(); - } - - // 5. Calculate sizes and token estimates - let totalSizeBytes = 0; - for (const s of allScreenshots) { - try { totalSizeBytes += fs.statSync(s.path).size; } catch {} - } - const totalSizeKB = Math.round(totalSizeBytes / 1024); - const estimatedTokens = allScreenshots.length * 2500; - - // 6. Print results - console.log(""); - console.log(` Captured ${allScreenshots.length} screenshot(s) across ${routes.length} route(s)`); - console.log(` Total size: ${totalSizeKB}KB`); - console.log(` Estimated token cost: ~${estimatedTokens} tokens`); - console.log(` Saved to: ${captureDir}`); - console.log(""); - console.log(" Screenshots:"); - for (const s of allScreenshots) { - const sizeKB = Math.round(fs.statSync(s.path).size / 1024); - console.log(` ${path.basename(s.path)} (${sizeKB}KB)`); - } - console.log(""); - console.log(" Ask Claude: \"Read the screenshots in .wolf/designqc-captures/ and evaluate the design\""); - - // 7. Log to memory - const now = new Date(); - const timeStr = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; - appendText( - path.join(this.wolfDir, "memory.md"), - `| ${timeStr} | designqc: captured ${allScreenshots.length} screenshots (${totalSizeKB}KB, ~${estimatedTokens} tok) | ${routes.join(", ")} | ready for eval | ~0 |\n`, - ); - - return { screenshots: allScreenshots, captureDir, totalSizeKB, estimatedTokens }; - } finally { - // 8. Kill dev server if we started it - if (weStartedServer && serverProc) { - console.log(" Stopping dev server..."); - serverProc.kill(); - } - } - } -} diff --git a/src/designqc/designqc-types.ts b/src/designqc/designqc-types.ts deleted file mode 100644 index a72ad968..00000000 --- a/src/designqc/designqc-types.ts +++ /dev/null @@ -1,34 +0,0 @@ -export interface DesignQCOptions { - targetFile?: string; - devServerUrl?: string; - routes?: string[]; - viewports?: Viewport[]; - maxScreenshots: number; - chromePath?: string; - quality: number; - maxWidth: number; -} - -export interface Viewport { - name: string; - width: number; - height: number; -} - -export interface Screenshot { - route: string; - viewport: Viewport; - path: string; -} - -export interface CaptureResult { - screenshots: Screenshot[]; - captureDir: string; - totalSizeKB: number; - estimatedTokens: number; -} - -export const DEFAULT_VIEWPORTS: Viewport[] = [ - { name: "desktop", width: 1440, height: 900 }, - { name: "mobile", width: 375, height: 812 }, -]; diff --git a/src/hooks/anatomy-lock.ts b/src/hooks/anatomy-lock.ts new file mode 100644 index 00000000..ccf3fea8 --- /dev/null +++ b/src/hooks/anatomy-lock.ts @@ -0,0 +1,104 @@ +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; +} + +/** Dependency-free synchronous sleep. */ +function sleep(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function tryAcquire(lockPath: string): boolean { + try { + const body: LockBody = { pid: process.pid, hostname: os.hostname(), acquiredAt: Date.now() }; + fs.writeFileSync(lockPath, JSON.stringify(body), { flag: "wx" }); + return true; + } catch { + return false; + } +} + +function isStale(lockPath: string): boolean { + try { + const body = JSON.parse(fs.readFileSync(lockPath, "utf-8")) as LockBody; + if (typeof body.acquiredAt !== "number") return true; + if (Date.now() - body.acquiredAt > STALE_MS) return true; + if (body.hostname === os.hostname() && typeof body.pid === "number") { + try { + process.kill(body.pid, 0); + return false; // owner alive + } catch (err) { + return (err as NodeJS.ErrnoException).code === "ESRCH"; + } + } + return false; + } catch { + // Unreadable/corrupt lock body: only age can save us; treat unreadable + // as stale so a garbage file cannot deadlock the system forever. + try { + const st = fs.statSync(lockPath); + return Date.now() - st.mtimeMs > STALE_MS; + } catch { + return false; // vanished — next acquire attempt will settle it + } + } +} + +/** Steal a stale lock. Rename is atomic: exactly one competing stealer wins. */ +function trySteal(lockPath: string): void { + const graveyard = lockPath + "." + crypto.randomBytes(4).toString("hex") + ".stale"; + try { + fs.renameSync(lockPath, graveyard); + try { fs.unlinkSync(graveyard); } catch {} + } catch { + // Someone else won the steal or the owner released — keep waiting. + } +} + +/** + * Run `fn` while holding the anatomy lock. Returns fn's result, or null if + * the lock could not be acquired within `budgetMs` (caller must degrade + * gracefully — skip the update, never block). + */ +export function withAnatomyLock(wolfDir: string, budgetMs: number, fn: () => T): T | null { + const lockPath = path.join(wolfDir, LOCK_FILE); + const deadline = Date.now() + budgetMs; + + while (true) { + if (tryAcquire(lockPath)) break; + if (isStale(lockPath)) trySteal(lockPath); + if (Date.now() >= deadline) return null; + sleep(25 + Math.floor(Math.random() * 25)); + } + + try { + return fn(); + } finally { + try { fs.unlinkSync(lockPath); } catch {} + } +} diff --git a/src/hooks/anatomy-store.ts b/src/hooks/anatomy-store.ts new file mode 100644 index 00000000..64eee3a1 --- /dev/null +++ b/src/hooks/anatomy-store.ts @@ -0,0 +1,338 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as crypto from "node:crypto"; + +// ───────────────────────────────────────────────────────────────────────────── +// Anatomy durable store (OPENWOLF-2.0 §F2b, Phase A). +// +// Source of truth: .wolf/anatomy-index.json. anatomy.md is a RENDERED artifact +// produced by renderStore() — byte-identical to the legacy serializeAnatomy +// format so every existing parser keeps working. Writers hold the anatomy lock +// (see anatomy-lock.ts) around load → mutate → save → render. +// +// This module is deliberately SELF-CONTAINED (no relative imports): it is the +// single canonical home of the anatomy format, it compiles standalone into the +// hooks bundle, and tests import it directly. +// ───────────────────────────────────────────────────────────────────────────── + +export interface AnatomyEntry { + file: string; + description: string; + tokens: number; +} + +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; +} + +export const STORE_FILE = "anatomy-index.json"; + +export function sha256(text: string): string { + return crypto.createHash("sha256").update(text).digest("hex"); +} + +export function newStore(): AnatomyStoreData { + return { + version: 1, + meta: { + lastScanned: new Date().toISOString(), + fileCount: 0, + hits: 0, + misses: 0, + renderedHash: "", + storeUpdatedAt: new Date().toISOString(), + }, + 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; // unknown shape — caller falls back to md import / rescan + } 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 {} + } +} + +// ── Markdown format (canonical — the legacy contract, unchanged) ──────────── + +export function parseAnatomy(content: string): Map { + const sections = new Map(); + 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) continue; + const em = line.match(/^- `([^`]+)`(?:\s+—\s+(.+?))?\s*\(~(\d+)\s+tok\)$/); + if (em) { + sections.get(currentSection)!.push({ + file: em[1], + description: em[2] || "", + tokens: parseInt(em[3], 10), + }); + } + } + return sections; +} + +export function serializeAnatomy( + sections: Map, + metadata: { lastScanned: string; fileCount: number; hits: number; misses: number } +): string { + const lines: string[] = [ + "# anatomy.md", + "", + `> Auto-maintained by OpenWolf. Last scanned: ${metadata.lastScanned}`, + `> Files: ${metadata.fileCount} tracked | Anatomy hits: ${metadata.hits} | Misses: ${metadata.misses}`, + "", + ]; + const keys = [...sections.keys()].sort(); + for (const key of keys) { + lines.push(`## ${key}`); + lines.push(""); + const entries = sections.get(key)!.sort((a, b) => a.file.localeCompare(b.file)); + for (const e of entries) { + const desc = e.description ? ` — ${e.description}` : ""; + lines.push(`- \`${e.file}\`${desc} (~${e.tokens} tok)`); + } + lines.push(""); + } + return lines.join("\n"); +} + +/** 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. For entries without symbols the output is + * byte-identical to the legacy serializeAnatomy format. Symbols render as + * two-space-indented sub-bullets, which every legacy parser skips (they match + * neither the section nor the entry regex): + * + * - `shared.ts` — Shared hook utilities (~3200 tok) + * - fn `parseAnatomy` L82-104 (~180 tok) + */ +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 keys = [...bySection.keys()].sort(); + for (const key of keys) { + lines.push(`## ${key}`); + lines.push(""); + const entries = bySection.get(key)!.sort((a, b) => a.file.localeCompare(b.file)); + for (const { file, entry } of entries) { + const desc = entry.description ? ` — ${entry.description}` : ""; + lines.push(`- \`${file}\`${desc} (~${entry.tokens} tok)`); + for (const sym of entry.symbols ?? []) { + lines.push(` - ${sym.kind} \`${sym.name}\` L${sym.startLine}-${sym.endLine} (~${sym.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 { + const sections = parseAnatomy(mdContent); + 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; +} diff --git a/src/hooks/post-read.ts b/src/hooks/post-read.ts index 39c1f98b..a95a86fd 100644 --- a/src/hooks/post-read.ts +++ b/src/hooks/post-read.ts @@ -1,5 +1,6 @@ import * as path from "node:path"; -import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, parseAnatomy, estimateTokens, readStdin, normalizePath, getProjectDir } from "./shared.js"; +import { getWolfDir, ensureWolfDir, readJSON, writeJSON, estimateTokens, readStdin, normalizePath, getProjectDir } from "./shared.js"; +import { lookupEntry } from "./anatomy-store.js"; interface SessionData { files_read: Record; @@ -44,20 +45,10 @@ async function main(): Promise { let tokens = content ? estimateTokens(content, type as "code" | "prose" | "mixed") : 0; - // Fallback: if tool_output had no content, use anatomy token estimate + // Fallback: if tool_output had no content, use the anatomy token estimate if (tokens === 0) { - const anatomyContent = readMarkdown(path.join(wolfDir, "anatomy.md")); - const sections = parseAnatomy(anatomyContent); - for (const [sectionKey, entries] of sections) { - for (const entry of entries) { - const entryRelPath = normalizePath(path.join(sectionKey, entry.file)); - if (normalizedFile.endsWith(entryRelPath) || normalizedFile.endsWith("/" + entryRelPath)) { - tokens = entry.tokens; - break; - } - } - if (tokens > 0) break; - } + const entry = lookupEntry(wolfDir, projectDir, normalizedFile); + if (entry) tokens = entry.tokens; } const session = readJSON(sessionFile, { files_read: {} }); diff --git a/src/hooks/post-write.ts b/src/hooks/post-write.ts index d8a1925e..89eb96b3 100644 --- a/src/hooks/post-write.ts +++ b/src/hooks/post-write.ts @@ -2,9 +2,24 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as crypto from "node:crypto"; import { - getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, parseAnatomy, serializeAnatomy, - extractDescription, estimateTokens, appendMarkdown, timeShort, readStdin, normalizePath, getProjectDir + getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, + extractDescription, estimateTokens, appendMarkdown, timeShort, readStdin, normalizePath, + isSensitiveFile, getProjectDir } from "./shared.js"; +import { loadStoreReconciled, saveStore, renderToFile, sha256 } from "./anatomy-store.js"; +import { withAnatomyLock, HOOK_LOCK_BUDGET_MS } from "./anatomy-lock.js"; +import { extractSymbols, symbolsSupported, SYMBOL_MIN_TOKENS } from "./symbol-extractor.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", +]); interface SessionData { files_written: Array<{ file: string; action: string; tokens: number; at: string }>; @@ -56,28 +71,24 @@ async function main(): Promise { const relPath = normalizePath(path.relative(projectRoot, absolutePath)); if (relPath.startsWith(".wolf/")) { process.exit(0); return; } - // Never track .env files in anatomy — they contain secrets + // Never track files outside the project root (e.g. the Claude Code scratchpad under + // /private/tmp). path.relative() yields ../.. section keys that pollute anatomy.md and are + // wiped again by every full `openwolf scan`, so the index churns instead of converging. + if (relPath.startsWith("..")) { process.exit(0); 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 (baseName === ".env" || baseName.startsWith(".env.")) { process.exit(0); return; } + if (isSensitiveFile(baseName)) { process.exit(0); return; } const oldStr = input.tool_input?.old_string ?? ""; const newStr = input.tool_input?.new_string ?? ""; - // 1. Update anatomy.md + // 1. Update the anatomy store, then re-render anatomy.md from it. + // All of this happens under the anatomy lock; if the lock cannot be + // acquired within budget we skip — a later writer converges the state. try { - const anatomyPath = path.join(wolfDir, "anatomy.md"); - let anatomyContent: string; - try { - anatomyContent = fs.readFileSync(anatomyPath, "utf-8"); - } catch { - anatomyContent = "# anatomy.md\n\n> Auto-maintained by OpenWolf.\n"; - } - - const sections = parseAnatomy(anatomyContent); const relPathLocal = normalizePath(path.relative(projectRoot, absolutePath)); - const dir = path.dirname(relPathLocal); - const fileName = path.basename(relPathLocal); - const sectionKey = dir === "." ? "./" : dir + "/"; let fileContent = ""; try { @@ -93,33 +104,37 @@ async function main(): Promise { const type = codeExts.has(ext) ? "code" : proseExts.has(ext) ? "prose" : "mixed"; const tokens = estimateTokens(fileContent, type as "code" | "prose" | "mixed"); - if (!sections.has(sectionKey)) sections.set(sectionKey, []); - const entries = sections.get(sectionKey)!; - const idx = entries.findIndex((e) => e.file === fileName); - if (idx !== -1) { - entries[idx] = { file: fileName, description: desc, tokens }; - } else { - entries.push({ file: fileName, description: desc, tokens }); - } - - let fileCount = 0; - for (const [, list] of sections) fileCount += list.length; - - const serialized = serializeAnatomy(sections, { - lastScanned: new Date().toISOString(), - fileCount, - hits: 0, - misses: 0, - }); - - const tmp = anatomyPath + "." + crypto.randomBytes(4).toString("hex") + ".tmp"; + let size: number | undefined; + let mtimeMs: number | undefined; try { - fs.writeFileSync(tmp, serialized, "utf-8"); - fs.renameSync(tmp, anatomyPath); - } catch { - try { fs.writeFileSync(anatomyPath, serialized, "utf-8"); } catch {} - try { fs.unlinkSync(tmp); } catch {} - } + const st = fs.statSync(absolutePath); + size = st.size; + mtimeMs = st.mtimeMs; + } catch {} + + // Symbols are recomputed on every write (never carried over — the + // content just changed, so old line ranges would misdirect slice reads). + const symbols = + tokens >= SYMBOL_MIN_TOKENS && symbolsSupported(ext) + ? extractSymbols(fileContent, ext) + : undefined; + + withAnatomyLock(wolfDir, HOOK_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", + symbols: symbols && symbols.length > 0 ? symbols : undefined, + }; + store.meta.lastScanned = new Date().toISOString(); + renderToFile(wolfDir, store); + saveStore(wolfDir, store); + }); } catch {} // 2. Append richer entry to memory.md @@ -283,12 +298,31 @@ function extractCalls(code: string): string[] { // ─── Auto Bug Detection ────────────────────────────────────────── +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; + } +} + 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 bugLogPath = path.join(wolfDir, "buglog.json"); const bugLog = readJSON(bugLogPath, { version: 1, bugs: [] }); const relFile = normalizePath(path.relative(projectRoot, absolutePath)); - const basename = path.basename(absolutePath); - const ext = path.extname(basename).toLowerCase(); // Detect what kind of fix this is const detection = detectFixPattern(oldStr, newStr, ext); diff --git a/src/hooks/pre-read.ts b/src/hooks/pre-read.ts index 0d5e947a..335b1407 100644 --- a/src/hooks/pre-read.ts +++ b/src/hooks/pre-read.ts @@ -1,8 +1,10 @@ +import * as fs from "node:fs"; import * as path from "node:path"; import { - getWolfDir, ensureWolfDir, readJSON, writeJSON, readMarkdown, parseAnatomy, + getWolfDir, ensureWolfDir, readJSON, writeJSON, estimateTokens, readStdin, normalizePath, getProjectDir } from "./shared.js"; +import { lookupEntry } from "./anatomy-store.js"; interface SessionData { session_id: string; @@ -62,24 +64,32 @@ async function main(): Promise { return; } - // Check anatomy.md for this file - const anatomyContent = readMarkdown(path.join(wolfDir, "anatomy.md")); - const sections = parseAnatomy(anatomyContent); - let found = false; + // Anatomy lookup: O(1) against the durable store, legacy md scan fallback. + const entry = lookupEntry(wolfDir, projectDir, normalizedFile); + const found = entry !== null; + if (entry) { + process.stderr.write( + `📋 OpenWolf anatomy: ${entry.file} — ${entry.description} (~${entry.tokens} tok)\n` + ); - for (const [sectionKey, entries] of sections) { - for (const entry of entries) { - // Build the full relative path from the section key + filename for accurate matching - const entryRelPath = normalizePath(path.join(sectionKey, entry.file)); - if (normalizedFile.endsWith(entryRelPath) || normalizedFile.endsWith("/" + entryRelPath)) { + // Symbol hint (F2b Phase B): 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("; "); process.stderr.write( - `📋 OpenWolf anatomy: ${entry.file} — ${entry.description} (~${entry.tokens} tok)\n` + ` ↳ symbols: ${list}. Read with offset/limit to fetch just the part you need.\n` ); - found = true; - break; } } - if (found) break; } if (found) { diff --git a/src/hooks/precompact.ts b/src/hooks/precompact.ts new file mode 100644 index 00000000..f10dc6a0 --- /dev/null +++ b/src/hooks/precompact.ts @@ -0,0 +1,36 @@ +import * as path from "node:path"; +import { getWolfDir, ensureWolfDir, readJSON, writeJSON, readStdin, timestamp } from "./shared.js"; + +// PreCompact hook (Workstream F3: compaction survival). +// +// Fires just before Claude Code / Codex compacts the context window. The +// in-flight session state (_session.json) survives on disk, but nothing in +// the compacted context tells the model what already happened. This hook: +// 1. snapshots the session state (belt-and-braces for post-mortems), and +// 2. after compaction, SessionStart fires with source "compact" — the +// session-start hook reads the same state and re-injects a digest via +// additionalContext. That pair is the survival mechanism. + +async function main(): Promise { + ensureWolfDir(); + const wolfDir = getWolfDir(); + const hooksDir = path.join(wolfDir, "hooks"); + + let input: { trigger?: string; session_id?: string } = {}; + try { + input = JSON.parse(await readStdin()); + } catch {} + + try { + const session = readJSON>(path.join(hooksDir, "_session.json"), {}); + writeJSON(path.join(hooksDir, "_precompact-snapshot.json"), { + at: timestamp(), + trigger: input.trigger ?? "unknown", + session, + }); + } catch {} + + process.exit(0); +} + +main().catch(() => process.exit(0)); diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index e197835f..c5669ad0 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -1,11 +1,107 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { getWolfDir, ensureWolfDir, writeJSON, appendMarkdown, readJSON, timestamp, timeShort } from "./shared.js"; +import { execFileSync } from "node:child_process"; +import { getWolfDir, ensureWolfDir, writeJSON, appendMarkdown, readJSON, timestamp, timeShort, estimateTokens, readStdin, detectAgent } from "./shared.js"; +import { loadStore } from "./anatomy-store.js"; + +// ─── Session digest (Workstream E/F: model-aware context budgeting) ───────── +// +// Instead of relying on the agent to read .wolf/ files, inject a compact +// digest of the highest-value state directly into the session's context via +// the SessionStart additionalContext channel — capped to a per-agent token +// budget so injection cost stays fixed and predictable. + +interface ContextConfig { + session_digest_budget_tokens?: number; + budgets?: Record; +} + +function digestBudget(wolfDir: string): number { + const cfg = readJSON<{ openwolf?: { context?: ContextConfig } }>( + path.join(wolfDir, "config.json"), {} + ); + const ctx = cfg.openwolf?.context ?? {}; + const agent = detectAgent(); + return ctx.budgets?.[agent] ?? ctx.session_digest_budget_tokens ?? 1500; +} + +/** Extract one `## heading` section (heading line through the next ## or ---). */ +function extractSection(markdown: string, headingPattern: RegExp): string { + const lines = markdown.split(/\r?\n/); + const start = lines.findIndex((l) => headingPattern.test(l)); + if (start === -1) return ""; + const out: string[] = [lines[start]]; + for (let i = start + 1; i < lines.length; i++) { + if (/^## /.test(lines[i]) || /^---\s*$/.test(lines[i])) break; + out.push(lines[i]); + } + return out.join("\n").trim(); +} + +function buildSessionDigest(wolfDir: string, budget: number): string { + const parts: string[] = []; + let used = 0; + const tryAdd = (text: string): boolean => { + if (!text) return false; + const cost = estimateTokens(text, "prose"); + if (used + cost > budget) return false; + parts.push(text); + used += cost; + return true; + }; + + // 1. STATUS.md "next phase" — the single most valuable resume context. + try { + const status = fs.readFileSync(path.join(wolfDir, "STATUS.md"), "utf-8"); + tryAdd(extractSection(status, /^## 🚀/)); + } catch {} + + // 2. Do-Not-Repeat list from cerebrum (most recent 10 entries). + try { + const cerebrum = fs.readFileSync(path.join(wolfDir, "cerebrum.md"), "utf-8"); + const dnr = extractSection(cerebrum, /^## Do-Not-Repeat/); + if (dnr) { + const entries = dnr.split("\n").filter((l) => l.startsWith("- ")); + if (entries.length > 0) { + tryAdd("## Do-Not-Repeat (from .wolf/cerebrum.md)\n" + entries.slice(-10).join("\n")); + } + } + } catch {} + + // 3. Recent known bugs — prevents re-deriving fixes (issue #45). + try { + const buglog = readJSON<{ bugs: Array<{ error_message?: string; fix?: string }> }>( + path.join(wolfDir, "buglog.json"), { bugs: [] } + ); + if (buglog.bugs.length > 0) { + const recent = buglog.bugs.slice(-5).map((b) => { + const line = `- ${b.error_message ?? "?"} → ${b.fix ?? "?"}`; + return line.length > 140 ? line.slice(0, 137) + "..." : line; + }); + tryAdd("## Known bugs already fixed (check .wolf/buglog.json before re-debugging)\n" + recent.join("\n")); + } + } catch {} + + // 4. Anatomy pointer (one line — the index itself stays on disk). + try { + const store = loadStore(wolfDir); + let count: number | null = store ? Object.keys(store.files).length : null; + if (count === null) { + const anatomy = fs.readFileSync(path.join(wolfDir, "anatomy.md"), "utf-8"); + const m = anatomy.match(/Files:\s*(\d+)\s*tracked/i); + if (m) count = parseInt(m[1], 10); + } + if (count !== null && count > 0) { + tryAdd(`anatomy.md tracks ${count} files with descriptions + token sizes — check it before reading any file.`); + } + } catch {} + + return parts.join("\n\n"); +} async function main(): Promise { ensureWolfDir(); const wolfDir = getWolfDir(); - const notices: string[] = []; // Clean up stale .tmp files left from failed atomic writes try { @@ -21,24 +117,36 @@ async function main(): Promise { const now = new Date(); const sessionId = `session-${now.toISOString().slice(0, 10)}-${String(now.getHours()).padStart(2, "0")}${String(now.getMinutes()).padStart(2, "0")}`; - // Create fresh session state - writeJSON(sessionFile, { - 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, - }); - - // Append session header to memory.md - const memoryPath = path.join(wolfDir, "memory.md"); - const header = `\n## Session: ${now.toISOString().slice(0, 10)} ${timeShort()}\n\n| Time | Action | File(s) | Outcome | ~Tokens |\n|------|--------|---------|---------|--------|\n`; - appendMarkdown(memoryPath, header); + // SessionStart fires for startup/resume/clear/compact. Only startup and + // clear begin a genuinely new session — on resume/compact the session is + // still live, and resetting _session.json would wipe read/write tracking + // mid-flight (Workstream F3: compaction survival). + let source = "startup"; + try { + const hookInput = JSON.parse(await readStdin()); + if (typeof hookInput.source === "string") source = hookInput.source; + } catch {} + const continuing = (source === "compact" || source === "resume") && fs.existsSync(sessionFile); + + if (!continuing) { + writeJSON(sessionFile, { + 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, + }); + + // Append session header to memory.md + const memoryPath = path.join(wolfDir, "memory.md"); + const header = `\n## Session: ${now.toISOString().slice(0, 10)} ${timeShort()}\n\n| Time | Action | File(s) | Outcome | ~Tokens |\n|------|--------|---------|---------|--------|\n`; + appendMarkdown(memoryPath, header); + } // Check cerebrum freshness — remind Claude to learn try { @@ -54,9 +162,13 @@ async function main(): Promise { }); if (entryLines.length < 3) { - notices.push(`OpenWolf: cerebrum.md has only ${entryLines.length} entries. Learn from this session and record preferences, conventions, and mistakes to .wolf/cerebrum.md.`); + process.stderr.write( + `💡 OpenWolf: cerebrum.md has only ${entryLines.length} entries. Learn from this session — record user preferences, project conventions, and mistakes to .wolf/cerebrum.md.\n` + ); } else if (daysSinceUpdate > 3) { - notices.push(`OpenWolf: cerebrum.md hasn't been updated in ${Math.floor(daysSinceUpdate)} days. Look for opportunities to add learnings this session.`); + process.stderr.write( + `💡 OpenWolf: cerebrum.md hasn't been updated in ${Math.floor(daysSinceUpdate)} days. Look for opportunities to add learnings this session.\n` + ); } } catch {} @@ -65,7 +177,9 @@ async function main(): Promise { const buglogPath = path.join(wolfDir, "buglog.json"); const buglog = readJSON<{ bugs: unknown[] }>(buglogPath, { bugs: [] }); if (buglog.bugs.length === 0) { - notices.push("OpenWolf: buglog.json is empty. If you encounter or fix bugs, errors, or failed tests this session, log them to .wolf/buglog.json."); + process.stderr.write( + `📋 OpenWolf: buglog.json is empty. If you encounter or fix any bugs, errors, or failed tests this session, log them to .wolf/buglog.json.\n` + ); } } catch {} @@ -79,16 +193,64 @@ async function main(): Promise { ledger.lifetime.total_sessions++; writeJSON(ledgerPath, ledger); - if (notices.length > 0) { - process.stdout.write(JSON.stringify({ - hookSpecificOutput: { - hookEventName: "SessionStart", - additionalContext: notices.join("\n"), - }, - })); - } + // Inject the budget-capped digest into the model's context. + try { + let digest = buildSessionDigest(wolfDir, digestBudget(wolfDir)); + + // Anatomy staleness detection (F2b): compare scan state against git HEAD + // and the configured rescan interval — detection is free; the agent can + // run the actual rescan. + const staleReason = anatomyStaleReason(wolfDir); + if (staleReason) { + digest = `⚠ anatomy.md may be stale (${staleReason}). Run \`openwolf scan\` before relying on it.\n\n` + digest; + } + + // Post-compaction restore (F3): resurface in-flight session state that + // compaction would otherwise erase. + if (continuing && source === "compact") { + const session = readJSON<{ files_written?: Array<{ file: string }>; edit_counts?: Record }>(sessionFile, {}); + const files = [...new Set((session.files_written ?? []).map((w) => w.file))]; + if (files.length > 0) { + digest = `## Session in progress (context was just compacted)\nFiles already modified this session: ${files.slice(-15).join(", ")}. Do not re-read them wholesale — check .wolf/memory.md for what was done.\n\n` + digest; + } + } + + if (digest) { + process.stdout.write(JSON.stringify({ + hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: digest }, + })); + } + } catch {} process.exit(0); } +// How the current anatomy scan state is stale, or null if fresh (F2b). +function anatomyStaleReason(wolfDir: string): string | null { + try { + const state = readJSON<{ last_scanned?: string; git_head?: string | null }>( + path.join(wolfDir, "_scan-state.json"), {} + ); + if (!state.last_scanned) return null; // no scan state yet — nothing to compare + + let currentHead: string | null = null; + try { + currentHead = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: path.dirname(wolfDir), encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000, + }).trim(); + } catch {} + if (state.git_head && currentHead && state.git_head !== currentHead) { + return "git HEAD moved since last scan"; + } + + const cfg = readJSON<{ openwolf?: { anatomy?: { rescan_interval_hours?: number } } }>( + path.join(wolfDir, "config.json"), {} + ); + const intervalH = cfg.openwolf?.anatomy?.rescan_interval_hours ?? 6; + const ageH = (Date.now() - new Date(state.last_scanned).getTime()) / 3600000; + if (ageH > intervalH) return `last scanned ${Math.floor(ageH)}h ago`; + } catch {} + return null; +} + main().catch(() => process.exit(0)); diff --git a/src/hooks/shared.ts b/src/hooks/shared.ts index bd267310..9a86a428 100644 --- a/src/hooks/shared.ts +++ b/src/hooks/shared.ts @@ -2,25 +2,29 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as crypto from "node:crypto"; +// 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. export function getProjectDir(): string { - // argv[2] is the first user argument after the script path, passed by hook config. - // Newer Claude Code may not run hooks from the project directory — rely on the - // explicit project-dir argument when available, falling back to process.cwd(). - const argDir = process.argv[2]; - if (argDir && fs.existsSync(argDir)) return argDir; - return process.cwd(); -} - -export function getHookProvider(): "claude" | "codex" { - const scriptPath = normalizePath(process.argv[1] || ""); - if (scriptPath.includes("/codex/")) return "codex"; - return "claude"; + return ( + process.env.CLAUDE_PROJECT_DIR || + process.env.CODEX_PROJECT_ROOT || + process.env.OPENWOLF_PROJECT_ROOT || + 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.CLAUDE_PROJECT_DIR) return "claude"; + if (process.env.CODEX_PROJECT_ROOT) return "codex"; + 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. @@ -69,58 +73,27 @@ export function appendMarkdown(filePath: string, line: string): void { fs.appendFileSync(filePath, line, "utf-8"); } -export interface AnatomyEntry { - file: string; - description: string; - tokens: number; -} - -export function parseAnatomy(content: string): Map { - const sections = new Map(); - let currentSection = ""; - for (const line of content.split("\n")) { - const sm = line.match(/^## (.+)/); - if (sm) { - currentSection = sm[1].trim(); - if (!sections.has(currentSection)) sections.set(currentSection, []); - continue; - } - if (!currentSection) continue; - const em = line.match(/^- `([^`]+)`(?:\s+—\s+(.+?))?\s*\(~(\d+)\s+tok\)$/); - if (em) { - sections.get(currentSection)!.push({ - file: em[1], - description: em[2] || "", - tokens: parseInt(em[3], 10), - }); - } - } - return sections; -} - -export function serializeAnatomy( - sections: Map, - metadata: { lastScanned: string; fileCount: number; hits: number; misses: number } -): string { - const lines: string[] = [ - "# anatomy.md", - "", - `> Auto-maintained by OpenWolf. Last scanned: ${metadata.lastScanned}`, - `> Files: ${metadata.fileCount} tracked | Anatomy hits: ${metadata.hits} | Misses: ${metadata.misses}`, - "", - ]; - const keys = [...sections.keys()].sort(); - for (const key of keys) { - lines.push(`## ${key}`); - lines.push(""); - const entries = sections.get(key)!.sort((a, b) => a.file.localeCompare(b.file)); - for (const e of entries) { - const desc = e.description ? ` — ${e.description}` : ""; - lines.push(`- \`${e.file}\`${desc} (~${e.tokens} tok)`); - } - lines.push(""); - } - return lines.join("\n"); +// parseAnatomy / serializeAnatomy / AnatomyEntry moved to ./anatomy-store.ts — +// the single canonical home of the anatomy format (OPENWOLF-2.0 §F2b). + +// Files whose contents (or content-derived descriptions) must never reach +// anatomy.md / memory.md because they hold secrets (issue #54). Kept in sync +// with the copy in src/scanner/anatomy-scanner.ts — hooks are standalone +// scripts and the scanner cannot be imported from here. +const SENSITIVE_EXTENSIONS = new Set([ + ".pem", ".key", ".p8", ".p12", ".pfx", ".keystore", ".jks", ".ppk", ".kdbx", ".tfstate", +]); +const SENSITIVE_BASENAMES = new Set([".npmrc", ".netrc", ".htpasswd", ".pgpass"]); + +export function isSensitiveFile(basename: 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 { @@ -603,3 +576,70 @@ export function readStdin(): Promise { export function normalizePath(p: string): string { return p.replace(/\\/g, "/"); } + +/** + * Count non-mechanical semantic entries written to memory.md today. + * Mechanical entries (auto-generated file ops, session-end lines) don't count. + * Used by the stop hook to detect whether Claude wrote a meaningful summary. + */ +export function countSemanticEntries(wolfDir: string): number { + const memoryPath = path.join(wolfDir, "memory.md"); + try { + const content = fs.readFileSync(memoryPath, "utf-8"); + const mechanical = /^\|\s*[\d:]+\s*\|\s*(Created|Edited|Multi-edited|Session end:|designqc:)/; + const today = new Date().toISOString().slice(0, 10); + const todayPrefix = `| ${today}`; + let count = 0; + for (const line of content.split("\n")) { + if (line.startsWith(todayPrefix) && !mechanical.test(line)) count++; + } + return count; + } catch { + return 0; + } +} + +// ─── Real token usage (Workstream F1) ──────────────────────────────────────── +// The Stop payload carries transcript_path; the transcript JSONL records the +// harness's actual per-message API usage. Summing it gives *measured* session +// tokens — the verifiable numbers the estimated ledger can be checked against. + +export interface RealUsage { + input_tokens: number; + output_tokens: number; + cache_read_input_tokens: number; + cache_creation_input_tokens: number; + api_calls: number; +} + +export function readTranscriptUsage(transcriptPath: string): RealUsage | null { + let raw: string; + try { + raw = fs.readFileSync(transcriptPath, "utf-8"); + } catch { + return null; + } + // One usage block per API call; streaming can emit several transcript lines + // for one message id — keep the last usage seen per id. + const byId = new Map(); + let anon = 0; + for (const line of raw.split("\n")) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line); + const usage = entry?.message?.usage; + if (usage && typeof usage === "object" && typeof usage.output_tokens === "number") { + byId.set(entry.message.id ?? `anon-${anon++}`, usage); + } + } catch {} + } + if (byId.size === 0) return null; + const total: RealUsage = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, api_calls: byId.size }; + for (const u of byId.values()) { + total.input_tokens += u.input_tokens ?? 0; + total.output_tokens += u.output_tokens ?? 0; + total.cache_read_input_tokens += u.cache_read_input_tokens ?? 0; + total.cache_creation_input_tokens += u.cache_creation_input_tokens ?? 0; + } + return total; +} diff --git a/src/hooks/stop.ts b/src/hooks/stop.ts index 34ac8451..d0c776e6 100644 --- a/src/hooks/stop.ts +++ b/src/hooks/stop.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { getWolfDir, ensureWolfDir, readJSON, writeJSON, appendMarkdown, timeShort } from "./shared.js"; +import { getWolfDir, ensureWolfDir, readJSON, writeJSON, appendMarkdown, timeShort, countSemanticEntries, readStdin, readTranscriptUsage, detectAgent, type RealUsage } from "./shared.js"; interface FileRead { count: number; @@ -30,6 +30,7 @@ interface SessionData { interface SessionEntry { id: string; + agent: string; started: string; ended: string; reads: Array<{ @@ -55,6 +56,12 @@ async function main(): Promise { const hooksDir = path.join(wolfDir, "hooks"); const sessionFile = path.join(hooksDir, "_session.json"); + // Stop payload → transcript path for real usage measurement (F1) + let hookInput: { transcript_path?: string } = {}; + try { + hookInput = JSON.parse(await readStdin()); + } catch {} + const session = readJSON(sessionFile, { session_id: "", started: "", @@ -80,11 +87,15 @@ async function main(): Promise { return; } - // Check for files edited many times without a buglog entry - checkForMissingBugLogs(wolfDir, session); + // Collect end-of-turn reminders — returned as strings, then surfaced via additionalContext + const reminders = [ + checkForMissingBugLogs(wolfDir, session), + checkCerebrumFreshness(wolfDir, session), + checkSemanticSummaries(wolfDir, session), + ].filter((r): r is string => r !== null); - // Check if cerebrum was updated this session (it should be if there were edits) - checkCerebrumFreshness(wolfDir, session); + // Check if STATUS.md is stale relative to this session + checkStatusFreshness(wolfDir, session); // Build session entry for ledger const reads = Object.entries(session.files_read).map(([file, data]) => ({ @@ -105,6 +116,7 @@ async function main(): Promise { const sessionEntry: SessionEntry = { id: session.session_id, + agent: detectAgent(), started: session.started, ended: new Date().toISOString(), reads, @@ -145,6 +157,20 @@ async function main(): Promise { [key: string]: unknown; }; + // Attach measured usage from the transcript when the harness provides it. + if (hookInput.transcript_path) { + const real = readTranscriptUsage(hookInput.transcript_path); + if (real) { + (sessionEntry as SessionEntry & { real_usage?: RealUsage }).real_usage = real; + const lt = ledger.lifetime as Record; + lt.real_input_tokens = (lt.real_input_tokens ?? 0) + real.input_tokens; + lt.real_output_tokens = (lt.real_output_tokens ?? 0) + real.output_tokens; + lt.real_cache_read_tokens = (lt.real_cache_read_tokens ?? 0) + real.cache_read_input_tokens; + lt.real_cache_creation_tokens = (lt.real_cache_creation_tokens ?? 0) + real.cache_creation_input_tokens; + lt.real_api_calls = (lt.real_api_calls ?? 0) + real.api_calls; + } + } + ledger.sessions.push(sessionEntry); ledger.lifetime.total_reads += readCount; ledger.lifetime.total_writes += writeCount; @@ -174,53 +200,108 @@ async function main(): Promise { writeJSON(sessionFile, session); + // Surface reminders via additionalContext so they appear in Claude's next context window. + // Using process.stdout JSON is the only reliable way for Stop hooks to inject content + // into Claude Code's context — process.stderr output goes to the terminal only. + if (reminders.length > 0) { + const additionalContext = `⚠️ OpenWolf end-of-turn reminders:\n${reminders.map(r => `• ${r}`).join("\n")}`; + process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "Stop", additionalContext } })); + } + process.exit(0); } /** * Check if files were edited multiple times but buglog.json wasn't updated. - * Emit a stderr reminder so Claude sees it in the next turn. + * Returns a reminder string if action is needed, otherwise null. */ -function checkForMissingBugLogs(wolfDir: string, session: SessionData): void { - if (!session.edit_counts) return; +function checkForMissingBugLogs(wolfDir: string, session: SessionData): string | null { + if (!session.edit_counts) return null; const multiEditFiles = Object.entries(session.edit_counts) .filter(([, count]) => count >= 3) .map(([file]) => path.basename(file)); - if (multiEditFiles.length === 0) return; + if (multiEditFiles.length === 0) return null; - // Check if buglog was written to this session const buglogWritten = session.files_written.some(w => w.file.includes("buglog.json") ); if (!buglogWritten) { - process.stderr.write( - `⚠️ OpenWolf: Files edited 3+ times this session (${multiEditFiles.join(", ")}) but buglog.json was not updated. If you fixed bugs, please log them.\n` - ); + return `ACTION REQUIRED: Files edited 3+ times this session (${multiEditFiles.join(", ")}) but buglog.json was not updated. Log the bug fixes to .wolf/buglog.json now.`; + } + return null; +} + +/** + * Check if STATUS.md is older than the session start AND there was meaningful + * code activity (3+ writes outside .wolf/). If so, nudge Claude to update + * STATUS.md so the next /clear has fresh handoff context. + */ +function checkStatusFreshness(wolfDir: string, session: SessionData): void { + const statusPath = path.join(wolfDir, "STATUS.md"); + const codeWrites = session.files_written.filter( + (w) => !w.file.includes("/.wolf/") && !w.file.endsWith(".tmp") + ); + + try { + const stat = fs.statSync(statusPath); + const sessionStartMs = session.started ? Date.parse(session.started) : 0; + if (!sessionStartMs) return; + + if (codeWrites.length >= 3 && stat.mtimeMs < sessionStartMs) { + process.stderr.write( + `📌 OpenWolf: STATUS.md not updated this session despite ${codeWrites.length} code writes. Update .wolf/STATUS.md (✅ done / 🚀 next quest) before /clear so next session resumes in 1 read.\n` + ); + } + } catch { + // STATUS.md doesn't exist — nudge to create it if there were code writes + if (codeWrites.length >= 3) { + process.stderr.write( + `📌 OpenWolf: .wolf/STATUS.md missing. Create it with current quest summary + next steps so /clear stays cheap.\n` + ); + } } } /** * Check if cerebrum.md was updated recently. If it hasn't been updated in - * a while and there was significant activity, emit a gentle reminder. + * a while and there was significant activity, return a reminder. */ -function checkCerebrumFreshness(wolfDir: string, session: SessionData): void { +function checkCerebrumFreshness(wolfDir: string, session: SessionData): string | null { const cerebrumPath = path.join(wolfDir, "cerebrum.md"); try { const stat = fs.statSync(cerebrumPath); const hoursSinceUpdate = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60); - // If cerebrum hasn't been updated in 24h+ and there were significant writes if (hoursSinceUpdate > 24 && session.files_written.length >= 3) { - process.stderr.write( - `💡 OpenWolf: cerebrum.md hasn't been updated in ${Math.floor(hoursSinceUpdate)}h. Did you learn any user preferences, conventions, or gotchas this session? Consider updating .wolf/cerebrum.md.\n` - ); + return `ACTION REQUIRED: cerebrum.md hasn't been updated in ${Math.floor(hoursSinceUpdate)}h and ${session.files_written.length} files were modified. Update .wolf/cerebrum.md with any new user preferences, conventions, or gotchas discovered this session.`; } } catch { // cerebrum.md doesn't exist, that's ok } + return null; } -main().catch(() => process.exit(0)); +/** + * Check if a semantic summary was written to memory.md this session. + * Returns a reminder string if action is needed, otherwise null. + */ +function checkSemanticSummaries(wolfDir: string, session: SessionData): string | null { + const writeCount = session.files_written.length; + if (writeCount < 2) return null; + + const semanticCount = countSemanticEntries(wolfDir); + if (semanticCount === 0) { + return `ACTION REQUIRED: ${writeCount} files were modified this session but no semantic summary was written to memory.md. Append a one-line summary: | HH:MM | description | file(s) | outcome | ~tokens |`; + } + return null; +} + +// Run only when executed as a hook script — never on import (tests import +// readTranscriptUsage, and main() exits the process). +import { pathToFileURL } from "node:url"; +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(() => process.exit(0)); +} diff --git a/src/hooks/symbol-extractor.ts b/src/hooks/symbol-extractor.ts new file mode 100644 index 00000000..95bb05d9 --- /dev/null +++ b/src/hooks/symbol-extractor.ts @@ -0,0 +1,94 @@ +import type { SymbolEntry } from "./anatomy-store.js"; + +// ───────────────────────────────────────────────────────────────────────────── +// Symbol-level anatomy (OPENWOLF-2.0 §F2b, Phase B). +// +// Extracts top-level symbols (name, kind, line range, token estimate) so the +// pre-read hint can point agents at a slice of a big file instead of the whole +// thing. Deliberately heuristic: line-anchored regexes for top-level +// declarations only; a symbol's end is the line before the next symbol. +// Crude ranges are fine for offset/limit reads. +// +// Self-contained (no relative imports) so it compiles standalone into the +// hooks bundle and tests can import it directly. +// ───────────────────────────────────────────────────────────────────────────── + +/** Only extract for files at least this many estimated tokens. */ +export const SYMBOL_MIN_TOKENS = 500; +/** Never extract from more than this much content. */ +export const SYMBOL_MAX_BYTES = 256 * 1024; +/** Cap symbols per file (in declaration order). */ +export const SYMBOL_MAX_COUNT = 30; + +interface Pattern { + re: RegExp; + kind: SymbolEntry["kind"]; +} + +const TS_JS: Pattern[] = [ + { re: /^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/, kind: "fn" }, + { re: /^(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/, kind: "class" }, + { re: /^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>/, kind: "fn" }, + { re: /^(?:export\s+)?interface\s+([A-Za-z_$][\w$]*)/, kind: "section" }, +]; + +const LANG_PATTERNS: Record = { + ".ts": TS_JS, ".tsx": TS_JS, ".js": TS_JS, ".jsx": TS_JS, ".mjs": TS_JS, ".cjs": TS_JS, + ".py": [ + { re: /^(?:async\s+)?def\s+(\w+)/, kind: "fn" }, + { re: /^class\s+(\w+)/, kind: "class" }, + ], + ".go": [ + { re: /^func\s+(?:\([^)]+\)\s+)?(\w+)/, kind: "fn" }, + { re: /^type\s+(\w+)\s+struct\b/, kind: "class" }, + { re: /^type\s+(\w+)\s+interface\b/, kind: "section" }, + ], + ".rs": [ + { re: /^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+(\w+)/, kind: "fn" }, + { re: /^(?:pub(?:\([^)]*\))?\s+)?struct\s+(\w+)/, kind: "class" }, + { re: /^(?:pub(?:\([^)]*\))?\s+)?enum\s+(\w+)/, kind: "class" }, + { re: /^impl(?:<[^>]*>)?\s+(?:[\w:<>]+\s+for\s+)?([\w]+)/, kind: "section" }, + ], +}; + +export function symbolsSupported(ext: string): boolean { + return ext.toLowerCase() in LANG_PATTERNS; +} + +/** + * Extract top-level symbols with 1-based line ranges. `ext` is the file + * extension including the dot. Tokens are chars/3.5 over the symbol's slice. + */ +export function extractSymbols(content: string, ext: string): SymbolEntry[] { + const patterns = LANG_PATTERNS[ext.toLowerCase()]; + if (!patterns || content.length > SYMBOL_MAX_BYTES) return []; + + const lines = content.split(/\r?\n/); + const found: Array<{ name: string; kind: SymbolEntry["kind"]; startLine: number }> = []; + + for (let i = 0; i < lines.length && found.length < SYMBOL_MAX_COUNT; i++) { + for (const { re, kind } of patterns) { + const m = lines[i].match(re); + if (m) { + found.push({ name: m[1], kind, startLine: i + 1 }); + break; + } + } + } + if (found.length === 0) return []; + + const symbols: SymbolEntry[] = []; + for (let i = 0; i < found.length; i++) { + const startLine = found[i].startLine; + const endLine = i + 1 < found.length ? found[i + 1].startLine - 1 : lines.length; + const sliceChars = lines.slice(startLine - 1, endLine).join("\n").length; + symbols.push({ + name: found[i].name, + kind: found[i].kind, + startLine, + endLine, + tokens: Math.ceil(sliceChars / 3.5), + }); + } + return symbols; +} diff --git a/src/scanner/anatomy-scanner.ts b/src/scanner/anatomy-scanner.ts index c058ce94..929ac497 100644 --- a/src/scanner/anatomy-scanner.ts +++ b/src/scanner/anatomy-scanner.ts @@ -1,16 +1,16 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import { execFileSync } from "node:child_process"; import { extractDescription, capDescription } from "./description-extractor.js"; -import { readJSON } from "../utils/fs-safe.js"; -import { writeText } from "../utils/fs-safe.js"; +import { + newStore, renderStore, renderToFile, saveStore, loadStoreReconciled, sha256, + type AnatomyStoreData, type StoreFileEntry, +} from "../hooks/anatomy-store.js"; +import { withAnatomyLock, CLI_LOCK_BUDGET_MS } from "../hooks/anatomy-lock.js"; +import { extractSymbols, symbolsSupported, SYMBOL_MIN_TOKENS } from "../hooks/symbol-extractor.js"; +import { readJSON, writeJSON, writeText } from "../utils/fs-safe.js"; import { normalizePath } from "../utils/paths.js"; -interface AnatomyEntry { - file: string; - description: string; - tokens: number; -} - interface WolfConfig { version: number; openwolf: { @@ -41,7 +41,7 @@ const BINARY_EXTENSIONS = new Set([ const CODE_EXTENSIONS = new Set([ ".ts", ".js", ".tsx", ".jsx", ".py", ".rs", ".go", ".java", ".c", ".cpp", ".h", ".css", ".scss", ".sql", ".sh", ".yaml", - ".yml", ".json", ".toml", ".xml", + ".yml", ".json", ".toml", ".xml", ".dart", ]); const PROSE_EXTENSIONS = new Set([".md", ".txt", ".rst", ".adoc"]); @@ -54,8 +54,24 @@ function estimateTokens(text: string, filePath: string): number { return Math.ceil(text.length / ratio); } -// Files that should never appear in anatomy (secrets, env files) -const ALWAYS_EXCLUDE_FILES = new Set([".env", ".env.local", ".env.production", ".env.staging", ".env.development"]); +// Files that should never appear in anatomy (secrets, env files, keys). +// Kept in sync with isSensitiveFile in src/hooks/shared.ts — hooks are +// standalone scripts and cannot import from the scanner build (issue #54). +const SENSITIVE_EXTENSIONS = new Set([ + ".pem", ".key", ".p8", ".p12", ".pfx", ".keystore", ".jks", ".ppk", ".kdbx", ".tfstate", +]); +const SENSITIVE_BASENAMES = new Set([".npmrc", ".netrc", ".htpasswd", ".pgpass"]); + +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; +} function shouldExclude( relPath: string, @@ -65,9 +81,7 @@ function shouldExclude( const basename = parts[parts.length - 1]; // Always exclude sensitive files regardless of config - if (ALWAYS_EXCLUDE_FILES.has(basename)) return true; - // Also exclude .env.* variants not in the set (e.g., .env.backup) - if (basename.startsWith(".env.") || basename === ".env") return true; + if (isSensitiveFile(basename)) return true; for (const pattern of excludePatterns) { // Simple glob: check if any path segment matches @@ -86,10 +100,9 @@ function walkDir( rootDir: string, excludePatterns: string[], maxFiles: number, - entries: Map + files: Record ): void { - let totalFiles = 0; - for (const [, list] of entries) totalFiles += list.length; + let totalFiles = Object.keys(files).length; if (totalFiles >= maxFiles) return; let items: fs.Dirent[]; @@ -108,14 +121,15 @@ function walkDir( if (shouldExclude(relPath, excludePatterns)) continue; if (item.isDirectory()) { - walkDir(fullPath, rootDir, excludePatterns, maxFiles, entries); + walkDir(fullPath, rootDir, excludePatterns, maxFiles, files); } else if (item.isFile()) { const ext = path.extname(item.name).toLowerCase(); if (BINARY_EXTENSIONS.has(ext)) continue; // Skip files > 1MB + let stat: fs.Stats; try { - const stat = fs.statSync(fullPath); + stat = fs.statSync(fullPath); if (stat.size > 1024 * 1024) continue; } catch { continue; @@ -131,18 +145,21 @@ function walkDir( const desc = capDescription(extractDescription(fullPath)); const tokens = estimateTokens(content, fullPath); - const section = normalizePath(path.relative(rootDir, dir)) || "."; - const sectionKey = section === "." ? "./" : section + "/"; - - if (!entries.has(sectionKey)) { - entries.set(sectionKey, []); - } + const symbols = + tokens >= SYMBOL_MIN_TOKENS && symbolsSupported(ext) + ? extractSymbols(content, ext) + : undefined; - entries.get(sectionKey)!.push({ - file: item.name, + files[relPath] = { description: desc, tokens, - }); + hash: sha256(content).slice(0, 16), + size: stat.size, + mtimeMs: stat.mtimeMs, + updatedAt: new Date().toISOString(), + source: "scan", + symbols: symbols && symbols.length > 0 ? symbols : undefined, + }; totalFiles++; if (totalFiles >= maxFiles) return; @@ -150,68 +167,11 @@ function walkDir( } } -export function serializeAnatomy( - sections: Map, - metadata: { lastScanned: string; fileCount: number; hits: number; misses: number } -): string { - const lines: string[] = [ - "# anatomy.md", - "", - `> Auto-maintained by OpenWolf. Last scanned: ${metadata.lastScanned}`, - `> Files: ${metadata.fileCount} tracked | Anatomy hits: ${metadata.hits} | Misses: ${metadata.misses}`, - "", - ]; - - const sortedKeys = [...sections.keys()].sort(); - - for (const key of sortedKeys) { - lines.push(`## ${key}`); - lines.push(""); - const entries = sections.get(key)!; - entries.sort((a, b) => a.file.localeCompare(b.file)); - for (const entry of entries) { - const desc = entry.description ? ` — ${entry.description}` : ""; - lines.push(`- \`${entry.file}\`${desc} (~${entry.tokens} tok)`); - } - lines.push(""); - } - - return lines.join("\n"); -} - -export function parseAnatomy(content: string): Map { - const sections = new Map(); - let currentSection = ""; - - for (const line of content.split("\n")) { - const sectionMatch = line.match(/^## (.+)/); - if (sectionMatch) { - currentSection = sectionMatch[1].trim(); - if (!sections.has(currentSection)) { - sections.set(currentSection, []); - } - continue; - } - - if (!currentSection) continue; - - const entryMatch = line.match(/^- `([^`]+)`(?:\s+—\s+(.+?))?\s*\(~(\d+)\s+tok\)$/); - if (entryMatch) { - sections.get(currentSection)!.push({ - file: entryMatch[1], - description: entryMatch[2] || "", - tokens: parseInt(entryMatch[3], 10), - }); - } - } - - return sections; -} /** * Scan the project and return the anatomy content and file count WITHOUT writing to disk. */ -export function buildAnatomy(wolfDir: string, projectRoot: string): { content: string; fileCount: number } { +export function buildAnatomy(wolfDir: string, projectRoot: string): { content: string; fileCount: number; store: AnatomyStoreData } { const configPath = path.join(wolfDir, "config.json"); const config = readJSON(configPath, { version: 1, @@ -225,96 +185,59 @@ export function buildAnatomy(wolfDir: string, projectRoot: string): { content: s }, }); - const entries = new Map(); + const store = newStore(); walkDir( projectRoot, projectRoot, config.openwolf.anatomy.exclude_patterns, config.openwolf.anatomy.max_files, - entries + store.files ); - let fileCount = 0; - for (const [, list] of entries) fileCount += list.length; - - const serialized = serializeAnatomy(entries, { - lastScanned: new Date().toISOString(), - fileCount, - hits: 0, - misses: 0, - }); - - return { content: serialized, fileCount }; + return { content: renderStore(store), fileCount: Object.keys(store.files).length, store }; } export function scanProject(wolfDir: string, projectRoot: string): number { - const { content, fileCount } = buildAnatomy(wolfDir, projectRoot); - const anatomyPath = path.join(wolfDir, "anatomy.md"); - writeText(anatomyPath, content); - return fileCount; -} - -export function updateAnatomyEntry( - wolfDir: string, - filePath: string, - projectRoot: string, - action: "upsert" | "delete" -): void { - const anatomyPath = path.join(wolfDir, "anatomy.md"); - let content: string; - try { - content = fs.readFileSync(anatomyPath, "utf-8"); - } catch { - content = "# anatomy.md\n\n> Auto-maintained by OpenWolf.\n"; - } - - const sections = parseAnatomy(content); - const relPath = normalizePath(path.relative(projectRoot, filePath)); - const dir = path.dirname(relPath); - const fileName = path.basename(relPath); - const sectionKey = dir === "." ? "./" : dir + "/"; - - if (action === "delete") { - const entries = sections.get(sectionKey); - if (entries) { - const idx = entries.findIndex((e) => e.file === fileName); - if (idx !== -1) entries.splice(idx, 1); - if (entries.length === 0) sections.delete(sectionKey); - } - } else { - // upsert - let fileContent: string; - try { - fileContent = fs.readFileSync(filePath, "utf-8"); - } catch { - return; - } - - const desc = capDescription(extractDescription(filePath)); - const tokens = estimateTokens(fileContent, filePath); - const entry: AnatomyEntry = { file: fileName, description: desc, tokens }; - - if (!sections.has(sectionKey)) { - sections.set(sectionKey, []); - } - const entries = sections.get(sectionKey)!; - const idx = entries.findIndex((e) => e.file === fileName); - if (idx !== -1) { - entries[idx] = entry; - } else { - entries.push(entry); + const { fileCount, store: fresh } = buildAnatomy(wolfDir, projectRoot); + + const result = withAnatomyLock(wolfDir, CLI_LOCK_BUDGET_MS, () => { + // Absorb md-side edits, then full-replace: the fresh disk walk defines + // the file set (this is the only code path allowed to delete entries). + const existing = loadStoreReconciled(wolfDir, projectRoot); + for (const [relPath, entry] of Object.entries(fresh.files)) { + const prev = existing.files[relPath]; + if (prev && ((prev.hash && prev.hash === entry.hash) || prev.source === "md-import")) { + // Content unchanged or human-edited: keep the curated description. + if (prev.description) entry.description = prev.description; + if (prev.hash === entry.hash && prev.symbols) entry.symbols = prev.symbols; + } } + existing.files = fresh.files; + existing.meta.lastScanned = new Date().toISOString(); + renderToFile(wolfDir, existing); + saveStore(wolfDir, existing); + return true; + }); + if (result === null) { + // Lock contention: fall back to writing the render directly (rare; the + // next locked writer reconciles via the md import path). + writeText(path.join(wolfDir, "anatomy.md"), renderStore(fresh)); } - let fileCount = 0; - for (const [, list] of sections) fileCount += list.length; - - const serialized = serializeAnatomy(sections, { - lastScanned: new Date().toISOString(), - fileCount, - hits: 0, - misses: 0, - }); + // Record scan state so hooks can detect staleness (git switches, editor + // edits outside an agent) without rescanning — Workstream F2b. + try { + let gitHead: string | null = null; + try { + gitHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: projectRoot, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim(); + } catch {} + writeJSON(path.join(wolfDir, "_scan-state.json"), { + last_scanned: new Date().toISOString(), + git_head: gitHead, + file_count: fileCount, + }); + } catch {} - writeText(anatomyPath, serialized); + return fileCount; } + diff --git a/src/templates/OPENWOLF.md b/src/templates/OPENWOLF.md index aae2a472..4af783cd 100644 --- a/src/templates/OPENWOLF.md +++ b/src/templates/OPENWOLF.md @@ -2,11 +2,32 @@ You are working in an OpenWolf-managed project. These rules apply every turn. +## STATUS.md — Single Source of Truth (READ FIRST) + +`.wolf/STATUS.md` is the **first file** you read when resuming a session. It contains: +- ✅ What is concluded (current quest finished) +- 🚀 Next quest (objective, files to create, decisions fixed/pending) +- 📁 Active architecture (stack, tables, patterns) +- ⚠️ External pendencies +- 🔧 Useful commands + +**At session start:** read `.wolf/STATUS.md` first. It replaces re-reading memory.md, plans, and code to reconstruct context. + +**MANDATORY — keep STATUS.md fresh:** +1. When the user signals a quest is done ("done", "complete", "ship it", "next phase", "/clear", "wrap up"): + - Move just-finished items from `🚀 Próxima fase` → `✅ Concluído`. + - Replace `🚀 Próxima fase` with the next planned quest (objective, files, decisions). + - Bump "Last updated" date. +2. After applying a migration, scaffolding a feature, or finishing a multi-file task: update STATUS.md before responding "done". +3. Before suggesting `/clear` to the user, ensure STATUS.md reflects the current state. + +**The bar is HIGH for STATUS.md.** Stale STATUS.md = wasted next session. Always treat it as the handoff document. + ## File Navigation 1. Check `.wolf/anatomy.md` BEFORE reading any file. It has a 2-3 line description and token estimate for every file in the project. 2. If the description in anatomy.md is sufficient for your task, do NOT read the full file. -3. If a file is not in anatomy.md, search with Grep/Glob, then update anatomy.md with the new entry. +3. If a file is not in anatomy.md, search with Grep/Glob. anatomy.md is rendered from `.wolf/anatomy-index.json`; you may edit descriptions in anatomy.md (they are absorbed on the next update) but do not reorder or reformat it. Regenerate with `openwolf scan`. ## Code Generation @@ -131,5 +152,6 @@ When the user asks to change, pick, migrate, or "reframe" their project's UI fra Before ending or when asked to wrap up: -1. Write a session summary to `.wolf/memory.md`. -2. Review the session: did you learn anything? Did the user correct you? Did you fix a bug? If yes, update `.wolf/cerebrum.md` and/or `.wolf/buglog.json`. +1. **Update `.wolf/STATUS.md`** — move concluded work to ✅, write next quest in 🚀, bump date. This is the most important step for next session efficiency. +2. Write a session summary to `.wolf/memory.md`. +3. Review the session: did you learn anything? Did the user correct you? Did you fix a bug? If yes, update `.wolf/cerebrum.md` and/or `.wolf/buglog.json`. diff --git a/src/templates/STATUS.md b/src/templates/STATUS.md new file mode 100644 index 00000000..8ea2abe6 --- /dev/null +++ b/src/templates/STATUS.md @@ -0,0 +1,64 @@ +# STATUS — {{PROJECT_NAME}} + +> Single source of truth for resuming work. Read this FIRST when starting a session. +> Update this file at the end of every work phase so the next `/clear` resumes in 1 read. +> Last updated: {{DATE}} + +--- + +## ✅ Done + + + +- (nothing yet — fill in as work completes) + +--- + +## 🚀 Next phase + +**Goal:** __ + +### Acceptance criteria +1. __ +2. _<...>_ + +### Files to create / edit +| Type | File | Content | +|---|---|---| +| new | `path/to/file.ts` | _what it does_ | + +### Closed decisions +- __ + +### Open decisions +- __ + +--- + +## 📁 Active architecture + +- **Stack:** __ +- **Key tables / modules:** __ +- **Patterns:** __ + +--- + +## ⚠️ External blockers (don't block coding) + +- __ + +--- + +## 🔧 Useful commands + +```bash +# add the most-used commands here so the next session has them ready +``` + +--- + +## 📚 References (read IF needed) + +- `.wolf/cerebrum.md` — User Preferences + Do-Not-Repeat + Decision Log +- `.wolf/anatomy.md` — token-efficient file index +- `.wolf/buglog.json` — known bugs + fixes diff --git a/src/templates/claude-rules-openwolf.md b/src/templates/claude-rules-openwolf.md index 97854106..db162881 100644 --- a/src/templates/claude-rules-openwolf.md +++ b/src/templates/claude-rules-openwolf.md @@ -3,6 +3,8 @@ description: OpenWolf protocol enforcement — active on all files globs: **/* --- +- Read .wolf/STATUS.md FIRST when resuming a session — it contains current quest, next steps, decisions +- Update .wolf/STATUS.md (✅ done / 🚀 next quest) when a quest finishes or before suggesting /clear - Check .wolf/anatomy.md before reading any project file - Check .wolf/cerebrum.md Do-Not-Repeat list before generating code - After writing or editing files, update .wolf/anatomy.md and append to .wolf/memory.md @@ -11,5 +13,4 @@ globs: **/* - BEFORE fixing any bug or error: read .wolf/buglog.json for known fixes - AFTER fixing any bug, error, failed test, failed build, or user-reported problem: ALWAYS log to .wolf/buglog.json with error_message, root_cause, fix, and tags - If you edit a file more than twice in a session, that likely indicates a bug — log it to .wolf/buglog.json -- When the user asks to check/evaluate UI design: run `openwolf designqc` to capture screenshots, then read them from .wolf/designqc-captures/ - When the user asks to change/pick/migrate UI framework: read .wolf/reframe-frameworks.md, ask decision questions, recommend a framework, then execute with the framework's prompt diff --git a/src/templates/config.json b/src/templates/config.json index d2c76aeb..3a267872 100644 --- a/src/templates/config.json +++ b/src/templates/config.json @@ -58,16 +58,18 @@ }, "dashboard": { "enabled": true, - "port": 18791 + "port": 18791, + "host": "127.0.0.1" }, - "designqc": { - "enabled": true, - "viewports": [ - { "name": "desktop", "width": 1440, "height": 900 }, - { "name": "mobile", "width": 375, "height": 812 } - ], - "max_screenshots": 6, - "chrome_path": null + "context": { + "session_digest_budget_tokens": 1500, + "budgets": { + "claude": 1500, + "codex": 1200, + "gemini": 1200, + "opencode": 1200, + "cursor": 800 + } } } } diff --git a/src/templates/opencode-md-snippet.md b/src/templates/opencode-md-snippet.md new file mode 100644 index 00000000..11942b2f --- /dev/null +++ b/src/templates/opencode-md-snippet.md @@ -0,0 +1,5 @@ +# OpenWolf + +@.wolf/OPENWOLF.md + +This project uses OpenWolf for context management. Read and follow .wolf/OPENWOLF.md every session. Check .wolf/cerebrum.md before generating code. Check .wolf/anatomy.md before reading files. diff --git a/src/templates/opencode-plugin/anatomy.ts b/src/templates/opencode-plugin/anatomy.ts new file mode 100644 index 00000000..515d163b --- /dev/null +++ b/src/templates/opencode-plugin/anatomy.ts @@ -0,0 +1,275 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import type { AnatomyEntry } from "./types.js" + +export function parseAnatomy(content: string): Map { + const sections = new Map() + 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) continue + const em = line.match(/^- `([^`]+)`(?:\s+—\s+(.+?))?\s*\(~(\d+)\s+tok\)$/) + if (em) { + sections.get(currentSection)!.push({ + file: em[1], + description: em[2] || "", + tokens: parseInt(em[3], 10), + }) + } + } + return sections +} + +export function serializeAnatomy( + sections: Map, + metadata: { lastScanned: string; fileCount: number; hits: number; misses: number } +): string { + const lines: string[] = [ + "# anatomy.md", + "", + `> Auto-maintained by OpenWolf. Last scanned: ${metadata.lastScanned}`, + `> Files: ${metadata.fileCount} tracked | Anatomy hits: ${metadata.hits} | Misses: ${metadata.misses}`, + "", + ] + const keys = [...sections.keys()].sort() + for (const key of keys) { + lines.push(`## ${key}`) + lines.push("") + const entries = sections.get(key)!.sort((a, b) => a.file.localeCompare(b.file)) + for (const e of entries) { + const desc = e.description ? ` — ${e.description}` : "" + lines.push(`- \`${e.file}\`${desc} (~${e.tokens} tok)`) + } + lines.push("") + } + return lines.join("\n") +} + +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 StoreFileEntry { + description: string + tokens: number + hash?: string + size?: number + mtimeMs?: number + updatedAt: string + source: "hook" | "scan" | "md-import" + symbols?: Array<{ name: string; kind: string; startLine: number; endLine: number; tokens: number }> +} + +export interface AnatomyStoreData { + version: 1 + meta: { lastScanned: string; fileCount: number; hits: number; misses: number; renderedHash: string; storeUpdatedAt: string } + files: Record +} + +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 {} + } +} + +export function renderStore(store: AnatomyStoreData): string { + const bySection = new Map>() + for (const [relPath, entry] of Object.entries(store.files)) { + const dir = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/") + 1) : "./" + if (!bySection.has(dir)) bySection.set(dir, []) + bySection.get(dir)!.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 keys = [...bySection.keys()].sort() + for (const key of keys) { + lines.push(`## ${key}`) + lines.push("") + const entries = bySection.get(key)!.sort((a, b) => a.file.localeCompare(b.file)) + for (const { file, entry } of entries) { + const desc = entry.description ? ` — ${entry.description}` : "" + lines.push(`- \`${file}\`${desc} (~${entry.tokens} tok)`) + for (const sym of entry.symbols ?? []) { + lines.push(` - ${sym.kind} \`${sym.name}\` L${sym.startLine}-${sym.endLine} (~${sym.tokens} tok)`) + } + } + lines.push("") + } + return lines.join("\n") +} + +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 {} + } +} + +export function importFromMarkdown(store: AnatomyStoreData, mdContent: string, projectRoot: string): void { + const sections = parseAnatomy(mdContent) + 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" + } + } + } + for (const relPath of Object.keys(store.files)) { + if (seen.has(relPath)) continue + if (!fs.existsSync(path.join(projectRoot, relPath))) delete store.files[relPath] + } +} + +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/src/templates/opencode-plugin/fs.ts b/src/templates/opencode-plugin/fs.ts new file mode 100644 index 00000000..6d651473 --- /dev/null +++ b/src/templates/opencode-plugin/fs.ts @@ -0,0 +1,64 @@ +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)) +} + +export function readJSON(filePath: string, fallback: T): T { + try { + return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T + } 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 {} + } +} + +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 }) + fs.appendFileSync(filePath, line, "utf-8") +} + +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) +} \ No newline at end of file diff --git a/src/templates/opencode-plugin/index.ts b/src/templates/opencode-plugin/index.ts new file mode 100644 index 00000000..eb5c11b7 --- /dev/null +++ b/src/templates/opencode-plugin/index.ts @@ -0,0 +1,99 @@ +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" + +export const OpenWolf: Plugin = async ({ directory }: { directory: string }) => { + return { + event: async ({ event }: { event: { type: string; [key: string]: unknown } }) => { + if (event.type === "session.created" && !wolfDirExists(directory)) return + + const sessionId = (event as any).session_id || (event as any).sessionID + if (!sessionId) return + + if (event.type === "session.created") { + handleSessionStart(directory, sessionId) + } + + if (event.type === "session.deleted") { + deleteSession(sessionId) + } + }, + + "tool.execute.before": async (input: { tool: string; sessionID: string }, output: { args: Record }) => { + if (!wolfDirExists(directory)) return + + const sessionId = input.sessionID + if (!sessionId) return + + const args: Record = output.args || {} + const tool = input.tool.toLowerCase() + + if (tool === "read") { + const filePath = String(args.filePath || args.file_path || "") + if (filePath) handlePreRead(directory, sessionId, filePath) + } + + 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 }, output: Record) => { + if (!wolfDirExists(directory)) return + + const sessionId = input.sessionID + 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) + } + + if (tool === "write" || tool === "edit") { + const filePath = 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 = (input as any).sessionID || (input as any).session_id + 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 = fs.readFileSync(openwolfPath, "utf-8") + output.system.push(`\n\n${openwolfContent}\n`) + } catch {} + } + }, + } +} \ No newline at end of file diff --git a/src/templates/opencode-plugin/post-read.ts b/src/templates/opencode-plugin/post-read.ts new file mode 100644 index 00000000..db74f711 --- /dev/null +++ b/src/templates/opencode-plugin/post-read.ts @@ -0,0 +1,57 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { getWolfDir, writeJSON, readJSON, normalizePath, readMarkdown, estimateTokens } from "./fs.js" +import { parseAnatomy } from "./anatomy.js" +import type { PartialSessionState } from "./types.js" + +export function handlePostRead(directory: string, sessionId: string, filePath: string, content: string): void { + const wolfDir = getWolfDir(directory) + if (!fs.existsSync(wolfDir)) return + + const hooksDir = path.join(wolfDir, "hooks") + const sessionFile = path.join(hooksDir, "_session.json") + 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 + + 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 + + if (tokens === 0) { + const anatomyContent = readMarkdown(path.join(wolfDir, "anatomy.md")) + const sections = parseAnatomy(anatomyContent) + for (const [, entries] of sections) { + for (const entry of entries) { + const entryRelPath = normalizePath(path.join(entry.file)) + if (normalizedFile.endsWith(entryRelPath) || normalizedFile.endsWith("/" + entryRelPath)) { + tokens = entry.tokens + break + } + } + if (tokens > 0) break + } + } + + const session = readJSON(sessionFile, { files_read: {} }) + if (!session.files_read) session.files_read = {} + + if (session.files_read[normalizedFile]) { + session.files_read[normalizedFile].tokens = tokens + } else { + session.files_read[normalizedFile] = { + count: 1, + tokens, + first_read: new Date().toISOString(), + } + } + + writeJSON(sessionFile, session) +} \ No newline at end of file diff --git a/src/templates/opencode-plugin/post-write.ts b/src/templates/opencode-plugin/post-write.ts new file mode 100644 index 00000000..609c9f02 --- /dev/null +++ b/src/templates/opencode-plugin/post-write.ts @@ -0,0 +1,270 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import * as crypto from "node:crypto" +import { getWolfDir, writeJSON, readJSON, appendMarkdown, timeShort, normalizePath, estimateTokens } from "./fs.js" +import { extractDescription, withAnatomyLock, loadStoreReconciled, saveStore, renderToFile, sha256, LOCK_BUDGET_MS } from "./anatomy.js" +import type { PartialSessionState, FixDetection } from "./types.js" + +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 = path.join(hooksDir, "_session.json") + 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 + + const baseName = path.basename(absolutePath) + if (baseName === ".env" || baseName.startsWith(".env.")) return + + updateAnatomy(wolfDir, absolutePath, projectRoot, content) + appendToMemory(wolfDir, toolName, absolutePath, projectRoot, content, newStr) + trackSession(wolfDir, sessionFile, filePath, toolName, content, newStr, baseName) + + 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( + wolfDir: string, + sessionFile: string, + filePath: string, + toolName: string, + content: string, + newStr: string, + baseName: string +): void { + try { + const session = readJSON(sessionFile, { files_written: [], edit_counts: {} }) + if (!session.edit_counts) session.edit_counts = {} + + const normalizedFile = normalizePath(filePath) + const action = toolName === "Write" ? "create" : "edit" + const fileContent = content ?? "" + const tokens = estimateTokens(fileContent || newStr, "code") + + session.files_written!.push({ + file: normalizedFile, + action, + tokens, + at: new Date().toISOString(), + }) + + const editKey = normalizePath(path.relative(wolfDir.replace("/.wolf", ""), path.join(wolfDir.replace("/.wolf", ""), filePath))) + session.edit_counts![editKey] = (session.edit_counts![editKey] || 0) + 1 + + writeJSON(sessionFile, session) + + if (session.edit_counts![editKey] >= 3) { + console.warn(`⚠️ OpenWolf: ${baseName} has been edited ${session.edit_counts![editKey]} times this session. If you're fixing a bug, remember to log it to .wolf/buglog.json.`) + } + } 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` +} + +export function autoDetectBugFix(wolfDir: string, absolutePath: string, projectRoot: string, oldStr: string, newStr: string): void { + const bugLogPath = path.join(wolfDir, "buglog.json") + const bugLog = readJSON<{ version: number; bugs: Array<{ id: string; timestamp: string; error_message: string; file: string; root_cause: string; fix: string; tags: string[]; related_bugs: string[]; occurrences: number; last_seen: string }> }>(bugLogPath, { version: 1, bugs: [] }) + const relFile = normalizePath(path.relative(projectRoot, absolutePath)) + const basename = path.basename(absolutePath) + const ext = path.extname(basename).toLowerCase() + + const detection = detectFixPattern(oldStr, newStr, ext, basename) + if (!detection) return + + const recentDupe = bugLog.bugs.find(b => { + if (path.basename(b.file) !== basename) return false + if (!b.tags.includes("auto-detected")) return false + if (!b.tags.includes(detection.category)) return false + const bugTime = new Date(b.last_seen).getTime() + return (Date.now() - bugTime) < 5 * 60 * 1000 + }) + + if (recentDupe) { + recentDupe.occurrences++ + recentDupe.last_seen = new Date().toISOString() + if (detection.context && !recentDupe.fix.includes(detection.context)) { + recentDupe.fix += ` | Also: ${detection.context}` + } + writeJSON(bugLogPath, bugLog) + return + } + + const nextId = `bug-${String(bugLog.bugs.length + 1).padStart(3, "0")}` + bugLog.bugs.push({ + id: nextId, + timestamp: new Date().toISOString(), + error_message: detection.summary, + file: relFile, + root_cause: detection.rootCause, + fix: detection.fix, + tags: ["auto-detected", detection.category, ext.replace(".", "") || "unknown"], + related_bugs: [], + occurrences: 1, + last_seen: new Date().toISOString(), + }) + writeJSON(bugLogPath, bugLog) +} + +export function detectFixPattern(oldStr: string, newStr: string, ext: string, basename: string): FixDetection | null { + const oldLines = oldStr.split("\n") + const newLines = newStr.split("\n") + + if (newStr.includes("catch") && !oldStr.includes("catch")) { + const fn = newStr.match(/(?:function|def|async)\s+(\w+)/)?.[1] || "unknown" + 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/src/templates/opencode-plugin/pre-read.ts b/src/templates/opencode-plugin/pre-read.ts new file mode 100644 index 00000000..0f4aa456 --- /dev/null +++ b/src/templates/opencode-plugin/pre-read.ts @@ -0,0 +1,63 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { getWolfDir, writeJSON, readJSON, normalizePath, readMarkdown } from "./fs.js" +import { parseAnatomy } from "./anatomy.js" +import type { PartialSessionState } from "./types.js" + +export function handlePreRead(directory: string, sessionId: string, filePath: string): void { + const wolfDir = getWolfDir(directory) + if (!fs.existsSync(wolfDir)) return + + const hooksDir = path.join(wolfDir, "hooks") + const sessionFile = path.join(hooksDir, "_session.json") + 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 + + 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 = {} + + if (session.files_read[normalizedFile]) { + const prev = session.files_read[normalizedFile] + console.warn(`⚡ OpenWolf: ${path.basename(normalizedFile)} was already read this session (~${prev.tokens} tokens). Consider using your existing knowledge of this file.`) + session.files_read[normalizedFile].count++ + session.repeated_reads_warned = (session.repeated_reads_warned || 0) + 1 + writeJSON(sessionFile, session) + return + } + + const anatomyContent = readMarkdown(path.join(wolfDir, "anatomy.md")) + const sections = parseAnatomy(anatomyContent) + let found = false + + for (const [sectionKey, entries] of sections) { + for (const entry of entries) { + const entryRelPath = normalizePath(path.join(sectionKey, entry.file)) + if (normalizedFile.endsWith(entryRelPath) || normalizedFile.endsWith("/" + entryRelPath)) { + console.warn(`📋 OpenWolf anatomy: ${entry.file} — ${entry.description} (~${entry.tokens} tok)`) + found = true + break + } + } + if (found) break + } + + session.anatomy_hits = (session.anatomy_hits || 0) + (found ? 1 : 0) + session.anatomy_misses = (session.anatomy_misses || 0) + (found ? 0 : 1) + + session.files_read[normalizedFile] = { + count: 1, + tokens: 0, + first_read: new Date().toISOString(), + } + + writeJSON(sessionFile, session) +} \ No newline at end of file diff --git a/src/templates/opencode-plugin/pre-write.ts b/src/templates/opencode-plugin/pre-write.ts new file mode 100644 index 00000000..3824094c --- /dev/null +++ b/src/templates/opencode-plugin/pre-write.ts @@ -0,0 +1,105 @@ +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) + } +} + +function checkCerebrum(wolfDir: string, content: string): void { + const cerebrumContent = readMarkdown(path.join(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): void { + const bugLogPath = path.join(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 + + 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/src/templates/opencode-plugin/session.ts b/src/templates/opencode-plugin/session.ts new file mode 100644 index 00000000..3dd1437a --- /dev/null +++ b/src/templates/opencode-plugin/session.ts @@ -0,0 +1,89 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { getWolfDir, writeJSON, readJSON, appendMarkdown, timeShort, timestamp, readMarkdown } 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 }) + + try { + const files = fs.readdirSync(wolfDir) + for (const f of files) { + if (f.endsWith(".tmp")) { + try { fs.unlinkSync(path.join(wolfDir, f)) } catch {} + } + } + } catch {} + + const sessionFile = path.join(hooksDir, "_session.json") + 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 {} + + 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 + } + ledger.lifetime.total_sessions++ + writeJSON(ledgerPath, ledger) +} \ No newline at end of file diff --git a/src/templates/opencode-plugin/stop.ts b/src/templates/opencode-plugin/stop.ts new file mode 100644 index 00000000..838c7e45 --- /dev/null +++ b/src/templates/opencode-plugin/stop.ts @@ -0,0 +1,126 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { getWolfDir, writeJSON, readJSON, appendMarkdown, timeShort } from "./fs.js" +import type { SessionState } from "./types.js" + +export function handleStop(directory: string, sessionId: string): void { + const wolfDir = getWolfDir(directory) + if (!fs.existsSync(wolfDir)) return + + const hooksDir = path.join(wolfDir, "hooks") + const sessionFile = path.join(hooksDir, "_session.json") + + const session = readJSON(sessionFile, { + session_id: "", started: "", files_read: {}, files_written: [], + edit_counts: {}, anatomy_hits: 0, anatomy_misses: 0, + repeated_reads_warned: 0, cerebrum_warnings: 0, stop_count: 0, + }) + + session.stop_count++ + + const readCount = Object.keys(session.files_read).length + const writeCount = session.files_written.length + + if (readCount === 0 && writeCount === 0) { + writeJSON(sessionFile, session) + return + } + + checkForMissingBugLogs(session) + buildLedgerEntry(wolfDir, session) + appendSessionSummary(wolfDir, session, readCount, writeCount) + + writeJSON(sessionFile, session) +} + +function checkForMissingBugLogs(session: SessionState): void { + if (!session.edit_counts) return + + const multiEditFiles = Object.entries(session.edit_counts) + .filter(([, count]) => count >= 3) + .map(([file]) => path.basename(file)) + + if (multiEditFiles.length > 0) { + const buglogWritten = session.files_written.some(w => w.file.includes("buglog.json")) + if (!buglogWritten) { + console.warn(`⚠️ OpenWolf: Files edited 3+ times this session (${multiEditFiles.join(", ")}) but buglog.json was not updated. If you fixed bugs, please log them.`) + } + } +} + +function buildLedgerEntry(wolfDir: string, session: SessionState): void { + const readCount = Object.keys(session.files_read).length + const writeCount = session.files_written.length + + const reads = Object.entries(session.files_read).map(([file, data]) => ({ + file, + tokens_estimated: data.tokens, + was_repeated: data.count > 1, + anatomy_had_description: false, + })) + + const writes = session.files_written.map((w) => ({ + file: w.file, + tokens_estimated: w.tokens, + action: w.action, + })) + + const inputTokens = reads.reduce((sum, r) => sum + r.tokens_estimated, 0) + const outputTokens = writes.reduce((sum, w) => sum + w.tokens_estimated, 0) + + const ledgerPath = path.join(wolfDir, "token-ledger.json") + const ledger = readJSON>(ledgerPath, { + version: 1, created_at: "", lifetime: { total_tokens_estimated: 0, total_reads: 0, total_writes: 0, total_sessions: 0, anatomy_hits: 0, anatomy_misses: 0, repeated_reads_blocked: 0, estimated_savings_vs_bare_cli: 0 }, + sessions: [] as Array<{ id: string; started: string; ended: string; reads: unknown[]; writes: unknown[]; totals: Record }>, + daemon_usage: [], waste_flags: [], optimization_report: { last_generated: null, patterns: [] }, + }) as { + version: number + lifetime: Record + sessions: Array<{ id: string; started: string; ended: string; reads: unknown[]; writes: unknown[]; totals: Record }> + [key: string]: unknown + } + + ledger.sessions.push({ + id: session.session_id, + started: session.started, + ended: new Date().toISOString(), + reads, + writes, + totals: { + input_tokens_estimated: inputTokens, + output_tokens_estimated: outputTokens, + reads_count: readCount, + writes_count: writeCount, + repeated_reads_blocked: session.repeated_reads_warned, + anatomy_lookups: session.anatomy_hits, + }, + }) + + ledger.lifetime.total_reads += readCount + ledger.lifetime.total_writes += writeCount + ledger.lifetime.total_tokens_estimated += inputTokens + outputTokens + ledger.lifetime.anatomy_hits += session.anatomy_hits + ledger.lifetime.anatomy_misses += session.anatomy_misses + ledger.lifetime.repeated_reads_blocked += session.repeated_reads_warned + + const savedFromAnatomy = session.anatomy_hits * 200 + const savedFromRepeats = Object.values(session.files_read) + .filter((r) => r.count > 1) + .reduce((sum, r) => sum + r.tokens * (r.count - 1), 0) + ledger.lifetime.estimated_savings_vs_bare_cli += savedFromAnatomy + savedFromRepeats + + writeJSON(ledgerPath, ledger) +} + +function appendSessionSummary(wolfDir: string, session: SessionState, readCount: number, writeCount: number): void { + if (writeCount > 0) { + try { + const inputTokens = Object.values(session.files_read).reduce((sum, r) => sum + r.tokens, 0) + const outputTokens = session.files_written.reduce((sum, w) => sum + w.tokens, 0) + const uniqueFiles = new Set(session.files_written.map(w => path.basename(w.file))) + const fileList = [...uniqueFiles].slice(0, 5).join(", ") + const memoryPath = path.join(wolfDir, "memory.md") + appendMarkdown(memoryPath, `| ${timeShort()} | Session end: ${writeCount} writes across ${uniqueFiles.size} files (${fileList}) | ${readCount} reads | ~${inputTokens + outputTokens} tok |\n`) + } catch {} + } +} \ No newline at end of file diff --git a/src/templates/opencode-plugin/types.ts b/src/templates/opencode-plugin/types.ts new file mode 100644 index 00000000..e426420f --- /dev/null +++ b/src/templates/opencode-plugin/types.ts @@ -0,0 +1,41 @@ +export interface FileRead { + count: number + tokens: number + first_read: string +} + +export interface FileWrite { + file: string + action: string + tokens: number + at: string +} + +export interface SessionState { + session_id: string + started: string + files_read: Record + files_written: FileWrite[] + edit_counts: Record + anatomy_hits: number + anatomy_misses: number + repeated_reads_warned: number + cerebrum_warnings: number + stop_count: number +} + +export type PartialSessionState = Partial + +export interface FixDetection { + category: string + summary: string + rootCause: string + fix: string + context?: string +} + +export interface AnatomyEntry { + file: string + description: string + tokens: number +} \ No newline at end of file diff --git a/src/templates/reframe-frameworks.md b/src/templates/reframe-frameworks.md index 2742fa4f..a9cd5a49 100644 --- a/src/templates/reframe-frameworks.md +++ b/src/templates/reframe-frameworks.md @@ -2,6 +2,37 @@ When the user asks to change, pick, migrate, or "reframe" their UI framework, use this file to guide the conversation and generate the right migration prompt. +## Design Principles — the anti-generic mandate (applies to EVERY prompt below) + +**Nothing you produce may look AI-generated.** The recognizable AI aesthetic is a +failure state, no matter which framework is chosen. Apply these rules to every +migration, every component, every page: + +**Never produce (the AI-tell blocklist):** +- Purple/indigo/violet gradient heroes, or gradient text on centered headlines +- Glassmorphism cards as the default surface; rounded-2xl + soft shadow on everything +- ✨ 🚀 🎉 emoji in headings or feature lists +- Generic 3-column icon–title–blurb feature grids +- Gradient blob / mesh backgrounds and meaningless floating 3D shapes +- The stock Tailwind palette used as-is; Inter (or system font) for every text role +- One uniform 8px spacing rhythm with no density variation anywhere +- The dark-purple SaaS landing template (hero → logo bar → 3 features → CTA) +- Filler copy ("Supercharge your workflow", "Built for developers, by developers") + +**Always aim for:** +- Typography chosen with intent: a deliberate pairing, optical sizes, tightened + tracking on display text — type does the branding work +- A palette derived from the product's actual domain and brand, not the framework's + default theme +- Asymmetry and grid-breaking where it serves hierarchy; density that matches the + audience (data tools can be dense; consumer pages can breathe) +- Copy that is specific to what this product does — never template filler +- **Distinctiveness as an acceptance criterion**: if the design could be swapped onto + any other product without anyone noticing, it fails review + +The `/reframe audit` and `/reframe fix` modes check and repair existing UI against +these rules; run them after any migration. + ## Decision Questions Ask these in order. Stop early if the answer narrows to 1-2 frameworks. @@ -22,8 +53,9 @@ Ask these in order. Stop early if the answer narrows to 1-2 frameworks. | Most free components | Origin UI (400+) | | Multi-framework (React + Vue + Svelte) | Park UI, DaisyUI, Flowbite | | AI product aesthetic | Cult UI | -| Polished defaults + accessibility | HeroUI, Chakra UI | +| Polished defaults + accessibility | HeroUI, Chakra UI, Astryx | | Business/enterprise | Flowbite, shadcn/ui | +| Full design system + theming, agent-friendly docs | Astryx | ## Comparison Matrix @@ -41,6 +73,7 @@ Ask these in order. Stop early if the answer narrows to 1-2 frameworks. | Origin UI | Tailwind + shadcn | Minimal | Easy (copy-paste) | Max component variety | Free | | Headless UI | Custom Tailwind | Custom (Transition) | Medium-Hard | Full design control | Free | | Cult UI | Tailwind + shadcn | Modern, subtle | Medium | AI apps, full-stack | Free | +| Astryx | StyleX-authored; override with anything | Built-in, subtle | Easy (npm, no build plugin) | Design-system apps, agent-assisted teams | Free (MIT) | --- @@ -48,6 +81,8 @@ Ask these in order. Stop early if the answer narrows to 1-2 frameworks. After the user selects a framework, use the corresponding prompt below. **Adapt it to the user's actual project** — replace generic references with their real file structure, existing routes, and components from `.wolf/anatomy.md`. +**The Design Principles section overrides anything generic in these prompts.** Where a prompt sketches a template structure (3-column feature grids, logo clouds, hero→features→CTA ordering), treat it as a checklist of *content to cover*, not a layout to copy — the layout must pass the distinctiveness criterion. + --- ### shadcn/ui @@ -595,3 +630,51 @@ CODE QUALITY: - Accessible interactive components - Production-ready architecture patterns ``` + +--- + +### Astryx + +**Stack:** React, StyleX-authored components (no styling lock-in — override with Tailwind, CSS modules, or plain CSS) +**Install:** `npm install @astryxdesign/core` + a theme package (`@astryxdesign/theme-*`); optional `@astryxdesign/cli` +**Site:** astryx.atmeta.com · github.com/facebook/astryx (MIT, beta) + +Meta's open design system: 160+ accessible, themeable React components, pre-built CSS +(no build plugin or PostCSS required), CSS-custom-property theming with seven ready +themes and dark mode built in. Two features matter for OpenWolf projects: component +**swizzling** (eject any component's source into the project for full control) and +**agent-ready docs** via the CLI/MCP — the docs are designed to be consumed by coding +agents, so migrations stay accurate. + +``` +Migrate this project's UI to Astryx (@astryxdesign/core). + +SETUP: +- Install @astryxdesign/core and one theme package; wire the theme at the app root +- No build plugin/PostCSS needed — components ship with pre-built CSS +- If the Astryx CLI is available, use its agent-ready docs (CLI or MCP) as the + source of truth for component APIs instead of guessing props + +ARCHITECTURE: +- Replace bespoke primitives (buttons, inputs, dialogs, badges, switches) with + Astryx components; keep app-specific composites as wrappers over Astryx parts +- Theme via CSS custom properties: generate a theme with the CLI or extend a + ready-made one — derive tokens from the product's actual brand, never ship the + default theme unmodified (see Design Principles above) +- Use swizzling only where a component must diverge structurally; prefer theming + and composition first +- Dark mode through Astryx's built-in scheme switching; verify every migrated + screen in both schemes + +MIGRATION ORDER: +1. Theme tokens (colors, type scale, spacing) mapped from the existing design +2. Leaf primitives (Button, Input, Badge, Switch, Checkbox) +3. Composites (forms, dialogs, menus, tables) +4. Page templates — check Astryx's production templates before rebuilding a page + from scratch, then de-genericize per the Design Principles + +CODE QUALITY: +- Keep existing component APIs stable where consumers depend on them (wrap, don't break) +- TypeScript throughout; Astryx components are fully typed +- Accessibility is built in — don't undo it with overrides; test keyboard paths +``` diff --git a/src/templates/skills/reframe.md b/src/templates/skills/reframe.md new file mode 100644 index 00000000..49d4b144 --- /dev/null +++ b/src/templates/skills/reframe.md @@ -0,0 +1,41 @@ +--- +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/src/templates/skills/security-audit.md b/src/templates/skills/security-audit.md new file mode 100644 index 00000000..2ba9685f --- /dev/null +++ b/src/templates/skills/security-audit.md @@ -0,0 +1,40 @@ +--- +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/src/utils/dashboard-auth.ts b/src/utils/dashboard-auth.ts new file mode 100644 index 00000000..a5bb84ca --- /dev/null +++ b/src/utils/dashboard-auth.ts @@ -0,0 +1,25 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as crypto from "node:crypto"; + +const TOKEN_FILE = "dashboard-token"; + +export function getDashboardToken(wolfDir: string): string { + const tokenPath = path.join(wolfDir, TOKEN_FILE); + try { + const existing = fs.readFileSync(tokenPath, "utf-8").trim(); + if (/^[a-f0-9]{64}$/.test(existing)) return existing; + } catch {} + + const token = crypto.randomBytes(32).toString("hex"); + fs.writeFileSync(tokenPath, token + "\n", { encoding: "utf-8", mode: 0o600 }); + return token; +} + +export function validateDashboardToken(wolfDir: string, token: string | null | undefined): boolean { + if (!token) return false; + const expected = getDashboardToken(wolfDir); + const a = Buffer.from(token); + const b = Buffer.from(expected); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} diff --git a/src/utils/fs-safe.ts b/src/utils/fs-safe.ts index 49d11f50..ee85b17a 100644 --- a/src/utils/fs-safe.ts +++ b/src/utils/fs-safe.ts @@ -2,10 +2,49 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as crypto from "node:crypto"; +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 config file predates a section a newer release reads. + */ export function readJSON(filePath: string, fallback: T): T { try { const raw = fs.readFileSync(filePath, "utf-8"); - return JSON.parse(raw) as T; + const parsed = JSON.parse(raw) as T; + return deepMergeDefaults(fallback, parsed); } catch { return fallback; } @@ -60,3 +99,19 @@ export function appendText(filePath: string, content: string): void { } fs.appendFileSync(filePath, content, "utf-8"); } + +// Drop-in replacement for fs.copyFileSync that works around a libuv/9P +// limitation: fs.copyFileSync uses the copy_file_range syscall on Linux, +// which fails with EPERM when writing to EFS-encrypted directories on +// Windows volumes mounted via WSL2 9P. Plain read+write bypasses +// copy_file_range and works in all cases. +export function safeCopyFile(src: string, dest: string): void { + const dir = path.dirname(dest); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(dest, fs.readFileSync(src)); + try { + fs.chmodSync(dest, fs.statSync(src).mode); + } catch {} +} diff --git a/tests/anatomy-lock.test.ts b/tests/anatomy-lock.test.ts new file mode 100644 index 00000000..dc95b03f --- /dev/null +++ b/tests/anatomy-lock.test.ts @@ -0,0 +1,76 @@ +import { test, describe } from "node:test"; +import * as assert from "node:assert"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +import { withAnatomyLock } from "../src/hooks/anatomy-lock.ts"; +import { loadStore } from "../src/hooks/anatomy-store.ts"; + +const tmpDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "wolf-lock-")); +const storeUrl = pathToFileURL(path.resolve(import.meta.dirname, "../src/hooks/anatomy-store.ts")).href; +const lockUrl = pathToFileURL(path.resolve(import.meta.dirname, "../src/hooks/anatomy-lock.ts")).href; + +/** One competing writer process: locked read-modify-write of a distinct key. */ +function writerScript(wolfDir: string, key: string): string { + return ` + const { withAnatomyLock } = await import(${JSON.stringify(lockUrl)}); + const { loadStore, saveStore, newStore } = await import(${JSON.stringify(storeUrl)}); + const wolfDir = ${JSON.stringify(wolfDir)}; + const ok = withAnatomyLock(wolfDir, 5000, () => { + const store = loadStore(wolfDir) ?? newStore(); + store.files[${JSON.stringify(key)}] = { description: "w", tokens: 1, updatedAt: "x", source: "hook" }; + saveStore(wolfDir, store); + return true; + }); + if (ok !== true) process.exit(3); + `; +} + +function runChild(script: string): Promise { + return new Promise((resolve) => { + const child = spawn(process.execPath, ["--input-type=module", "-e", script], { stdio: "ignore" }); + child.on("exit", (code) => resolve(code ?? 1)); + }); +} + +describe("anatomy lock", () => { + test("no lost updates: 8 concurrent writer processes, 8 surviving keys", async () => { + const dir = tmpDir(); + const codes = await Promise.all( + Array.from({ length: 8 }, (_, i) => runChild(writerScript(dir, `src/file-${i}.ts`))) + ); + assert.deepStrictEqual(codes, [0, 0, 0, 0, 0, 0, 0, 0]); + const store = loadStore(dir); + assert.ok(store); + assert.strictEqual(Object.keys(store!.files).length, 8, "every concurrent upsert must survive"); + }); + + test("stale lock (dead pid, old timestamp) is stolen and work proceeds", () => { + const dir = tmpDir(); + fs.writeFileSync( + path.join(dir, "anatomy-index.lock"), + JSON.stringify({ pid: 999999, hostname: os.hostname(), acquiredAt: Date.now() - 60_000 }), + "utf-8" + ); + const result = withAnatomyLock(dir, 3000, () => "ran"); + assert.strictEqual(result, "ran"); + assert.ok(!fs.existsSync(path.join(dir, "anatomy-index.lock")), "lock released after work"); + }); + + test("held lock (live pid, fresh) times out to null within budget", () => { + const dir = tmpDir(); + fs.writeFileSync( + path.join(dir, "anatomy-index.lock"), + JSON.stringify({ pid: process.pid, hostname: os.hostname(), acquiredAt: Date.now() }), + "utf-8" + ); + const started = Date.now(); + const result = withAnatomyLock(dir, 300, () => "ran"); + assert.strictEqual(result, null, "must degrade, never run"); + assert.ok(Date.now() - started < 2000, "returns promptly after budget"); + fs.unlinkSync(path.join(dir, "anatomy-index.lock")); + }); +}); diff --git a/tests/anatomy-store.test.ts b/tests/anatomy-store.test.ts new file mode 100644 index 00000000..29621f99 --- /dev/null +++ b/tests/anatomy-store.test.ts @@ -0,0 +1,142 @@ +import { test, describe } from "node:test"; +import * as assert from "node:assert"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { + newStore, saveStore, loadStore, loadStoreReconciled, renderStore, renderToFile, + importFromMarkdown, parseAnatomy, sha256, lookupEntry, STORE_FILE, +} from "../src/hooks/anatomy-store.ts"; + +const tmpDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "wolf-anat-")); + +function sampleStore() { + const store = newStore(); + store.meta.lastScanned = "2026-07-15T00:00:00.000Z"; + store.files["src/index.ts"] = { description: "Main entry point", tokens: 180, updatedAt: "x", source: "scan" }; + store.files["src/api/auth.ts"] = { description: "JWT middleware", tokens: 340, updatedAt: "x", source: "scan" }; + store.files["README.md"] = { description: "", tokens: 900, updatedAt: "x", source: "scan" }; + return store; +} + +describe("anatomy store", () => { + test("save/load round-trip is lossless", () => { + const dir = tmpDir(); + const store = sampleStore(); + saveStore(dir, store); + const loaded = loadStore(dir); + assert.ok(loaded); + assert.deepStrictEqual(loaded!.files, store.files); + assert.strictEqual(loaded!.meta.fileCount, 3); + }); + + test("corrupt store returns null; reconciled loader bootstraps from anatomy.md", () => { + const dir = tmpDir(); + fs.writeFileSync(path.join(dir, STORE_FILE), "{not json", "utf-8"); + assert.strictEqual(loadStore(dir), null); + fs.writeFileSync(path.join(dir, "anatomy.md"), renderStore(sampleStore()), "utf-8"); + const recovered = loadStoreReconciled(dir, dir); + assert.strictEqual(Object.keys(recovered.files).length, 3); + assert.strictEqual(recovered.files["src/api/auth.ts"].description, "JWT middleware"); + }); + + test("render is byte-identical to the legacy format (golden)", () => { + const rendered = renderStore(sampleStore()); + const expected = [ + "# anatomy.md", + "", + "> Auto-maintained by OpenWolf. Last scanned: 2026-07-15T00:00:00.000Z", + "> Files: 3 tracked | Anatomy hits: 0 | Misses: 0", + "", + "## ./", + "", + "- `README.md` (~900 tok)", + "", + "## src/", + "", + "- `index.ts` — Main entry point (~180 tok)", + "", + "## src/api/", + "", + "- `auth.ts` — JWT middleware (~340 tok)", + "", + ].join("\n"); + assert.strictEqual(rendered, expected); + }); + + test("parse(render(store)) preserves every entry", () => { + const sections = parseAnatomy(renderStore(sampleStore())); + const flat = [...sections.values()].flat(); + assert.strictEqual(flat.length, 3); + assert.ok(flat.some((e) => e.file === "auth.ts" && e.tokens === 340)); + }); +}); + +describe("markdown import (reconciliation)", () => { + test("md wins description; unknown md entries added; missing-but-alive entries kept", () => { + const dir = tmpDir(); + fs.mkdirSync(path.join(dir, "src"), { recursive: true }); + fs.writeFileSync(path.join(dir, "src/kept.ts"), "export {}", "utf-8"); + + const store = newStore(); + store.files["src/kept.ts"] = { description: "old desc", tokens: 10, updatedAt: "x", source: "hook" }; + store.files["src/gone.ts"] = { description: "dead", tokens: 5, updatedAt: "x", source: "hook" }; + + const md = [ + "# anatomy.md", "", + "> Auto-maintained by OpenWolf. Last scanned: t", + "> Files: 2 tracked | Anatomy hits: 0 | Misses: 0", "", + "## src/", "", + "- `kept.ts` — HAND EDITED (~10 tok)", + "- `brandnew.ts` — from old hook (~77 tok)", "", + ].join("\n"); + + importFromMarkdown(store, md, dir); + assert.strictEqual(store.files["src/kept.ts"].description, "HAND EDITED"); + assert.strictEqual(store.files["src/brandnew.ts"].tokens, 77); + assert.strictEqual(store.files["src/brandnew.ts"].source, "md-import"); + assert.ok(!("src/gone.ts" in store.files), "file gone from disk AND md is dropped"); + }); + + test("render → import → render is a fixed point", () => { + const dir = tmpDir(); + const store = sampleStore(); + const first = renderStore(store); + importFromMarkdown(store, first, dir); + const second = renderStore(store); + assert.strictEqual(second, first); + }); + + test("diverged md is absorbed via loadStoreReconciled (renderedHash mismatch)", () => { + const dir = tmpDir(); + const store = sampleStore(); + renderToFile(dir, store); + saveStore(dir, store); + // Simulate an old compiled hook / human editing the md out-of-band: + const edited = fs.readFileSync(path.join(dir, "anatomy.md"), "utf-8") + .replace("Main entry point", "EDITED BY OLD HOOK"); + fs.writeFileSync(path.join(dir, "anatomy.md"), edited, "utf-8"); + const reconciled = loadStoreReconciled(dir, dir); + assert.strictEqual(reconciled.files["src/index.ts"].description, "EDITED BY OLD HOOK"); + }); +}); + +describe("lookupEntry", () => { + test("store-first O(1) hit, suffix fallback, and md fallback all resolve", () => { + const dir = tmpDir(); + const store = sampleStore(); + saveStore(dir, store); + const proj = "/Users/someone/proj"; + const hit = lookupEntry(dir, proj, `${proj}/src/api/auth.ts`); + assert.ok(hit && hit.tokens === 340 && hit.file === "auth.ts"); + + // md fallback: no store present + const dir2 = tmpDir(); + fs.writeFileSync(path.join(dir2, "anatomy.md"), renderStore(sampleStore()), "utf-8"); + const hit2 = lookupEntry(dir2, proj, `${proj}/src/index.ts`); + assert.ok(hit2 && hit2.tokens === 180); + + assert.strictEqual(lookupEntry(dir, proj, `${proj}/src/nope.ts`), null); + }); +}); diff --git a/tests/security.test.ts b/tests/security.test.ts new file mode 100644 index 00000000..e42a143c --- /dev/null +++ b/tests/security.test.ts @@ -0,0 +1,105 @@ +import { test, describe } from "node:test"; +import * as assert from "node:assert"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import { execFileSync } from "node:child_process"; + +// Security regression suite. Origin: PR #34 (riverwolf67), extended when +// reconciling with PR #30 (svanack404). + +describe("command injection", () => { + test("execFileSync passes metacharacters through as literal arguments", () => { + if (process.platform === "win32") return; + const scriptPath = path.join(os.tmpdir(), `openwolf-sec-${process.pid}.sh`); + const maliciousArg = "safe; echo 'pwned'"; + fs.writeFileSync(scriptPath, '#!/bin/bash\necho "ARG: $1"', { mode: 0o755 }); + try { + const output = execFileSync(scriptPath, [maliciousArg], { encoding: "utf-8" }); + assert.strictEqual(output.trim(), `ARG: ${maliciousArg}`); + } finally { + fs.unlinkSync(scriptPath); + } + }); + + test("no string-interpolated execSync remains for dynamic values", () => { + // Static `which x || which y` probes are allowed; anything interpolating + // a runtime value (port, name, path) must use execFileSync array args. + const offenders: string[] = []; + const walk = (dir: string): void => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) { walk(p); continue; } + if (!p.endsWith(".ts")) continue; + const src = fs.readFileSync(p, "utf-8"); + for (const m of src.matchAll(/execSync\((`[^`]*\$\{[^`]*`)/g)) { + offenders.push(`${p}: ${m[1]}`); + } + } + }; + walk(path.resolve(import.meta.dirname, "..", "src")); + assert.deepStrictEqual(offenders, []); + }); +}); + +describe("dashboard auth", () => { + test("token is generated once, 64 hex chars, mode 0600", async () => { + const { getDashboardToken, validateDashboardToken } = await import("../src/utils/dashboard-auth.ts"); + const wolfDir = fs.mkdtempSync(path.join(os.tmpdir(), "wolf-sec-")); + const t1 = getDashboardToken(wolfDir); + const t2 = getDashboardToken(wolfDir); + assert.match(t1, /^[a-f0-9]{64}$/); + assert.strictEqual(t1, t2, "token must be stable across calls"); + if (process.platform !== "win32") { + const mode = fs.statSync(path.join(wolfDir, "dashboard-token")).mode & 0o777; + assert.strictEqual(mode, 0o600); + } + assert.strictEqual(validateDashboardToken(wolfDir, t1), true); + assert.strictEqual(validateDashboardToken(wolfDir, "0".repeat(64)), false); + assert.strictEqual(validateDashboardToken(wolfDir, null), false); + assert.strictEqual(validateDashboardToken(wolfDir, ""), false); + }); +}); + +describe("path traversal", () => { + test("resolve+relative check rejects escapes, accepts inside paths", () => { + const projectRoot = path.resolve(os.tmpdir(), "fake-project"); + const check = (file: string): boolean => { + const resolved = path.resolve(projectRoot, file); + const rel = path.relative(projectRoot, resolved); + return !(rel.startsWith("..") || path.isAbsolute(rel)); + }; + assert.strictEqual(check("../../etc/passwd"), false); + assert.strictEqual(check("/etc/passwd"), false); + assert.strictEqual(check("src/index.ts"), true); + assert.strictEqual(check("./README.md"), true); + }); +}); + +describe("secret file redaction (issue #54)", () => { + test("isSensitiveFile covers keys, stores, credentials — not normal files", async () => { + const { isSensitiveFile } = await import("../src/hooks/shared.ts"); + for (const f of [ + ".env", ".env.local", "server.pem", "signing.key", "apns.p8", + "release.keystore", "trust.jks", "id_rsa", "id_ed25519.pub", + "gcp-credentials.json", "secrets.yaml", ".npmrc", ".netrc", + "terraform.tfstate", "putty.ppk", "vault.kdbx", + ]) { + assert.strictEqual(isSensitiveFile(f), true, `${f} should be sensitive`); + } + for (const f of [ + "index.ts", "README.md", "package.json", "environment.ts", + "key-codes.ts", "monkey.test.ts", "envelope.tsx", + ]) { + assert.strictEqual(isSensitiveFile(f), false, `${f} should NOT be sensitive`); + } + }); +}); + +describe("file watcher DoS guard", () => { + test("1 MB broadcast cap logic", () => { + const overLimit = (size: number): boolean => size > 1024 * 1024; + assert.strictEqual(overLimit(1024 * 1024 + 1), true); + assert.strictEqual(overLimit(1024 * 1024), false); + }); +}); diff --git a/tests/symbol-extractor.test.ts b/tests/symbol-extractor.test.ts new file mode 100644 index 00000000..f26de6b5 --- /dev/null +++ b/tests/symbol-extractor.test.ts @@ -0,0 +1,98 @@ +import { test, describe } from "node:test"; +import * as assert from "node:assert"; + +import { extractSymbols, symbolsSupported, SYMBOL_MAX_BYTES, SYMBOL_MAX_COUNT } from "../src/hooks/symbol-extractor.ts"; +import { parseAnatomy, renderStore, newStore } from "../src/hooks/anatomy-store.ts"; + +describe("symbol extraction per language", () => { + test("typescript: functions, classes, arrows, interfaces; ranges chain correctly", () => { + const src = [ + "import * as x from 'y';", // 1 + "", // 2 + "export function alpha(a: number) {", // 3 + " return a + 1;", // 4 + "}", // 5 + "", // 6 + "export const beta = (b: string) => {", // 7 + " return b.trim();", // 8 + "};", // 9 + "", // 10 + "export class Gamma {", // 11 + " method() { return 1; }", // 12 + "}", // 13 + "", // 14 + "export interface Delta {", // 15 + " x: number;", // 16 + "}", // 17 + ].join("\n"); + const syms = extractSymbols(src, ".ts"); + assert.deepStrictEqual(syms.map((s) => [s.name, s.kind, s.startLine, s.endLine]), [ + ["alpha", "fn", 3, 6], + ["beta", "fn", 7, 10], + ["Gamma", "class", 11, 14], + ["Delta", "section", 15, 17], + ]); + assert.ok(syms.every((s) => s.tokens > 0)); + }); + + test("python: top-level defs and classes only (indented defs excluded)", () => { + const src = "import os\n\ndef top(a):\n return a\n\nclass Thing:\n def method(self):\n pass\n\nasync def later():\n pass\n"; + const syms = extractSymbols(src, ".py"); + assert.deepStrictEqual(syms.map((s) => [s.name, s.kind]), [ + ["top", "fn"], ["Thing", "class"], ["later", "fn"], + ]); + }); + + test("go: funcs with receivers, structs, interfaces", () => { + const src = "package main\n\nfunc Plain() {}\n\nfunc (s *Server) Handle(w W, r R) {}\n\ntype Server struct {\n port int\n}\n\ntype Handler interface {\n Do()\n}\n"; + const syms = extractSymbols(src, ".go"); + assert.deepStrictEqual(syms.map((s) => [s.name, s.kind]), [ + ["Plain", "fn"], ["Handle", "fn"], ["Server", "class"], ["Handler", "section"], + ]); + }); + + test("rust: fns incl. pub/async, structs, enums, impl blocks", () => { + const src = "use std::io;\n\npub fn public_fn() {}\n\npub(crate) async fn crate_fn() {}\n\nstruct Point {\n x: i32,\n}\n\npub enum Mode {\n A,\n}\n\nimpl Display for Point {\n fn fmt(&self) {}\n}\n"; + const syms = extractSymbols(src, ".rs"); + const names = syms.map((s) => s.name); + assert.deepStrictEqual(names, ["public_fn", "crate_fn", "Point", "Mode", "Point"]); + }); + + test("CRLF content extracts identically to LF", () => { + const lf = "export function one() {\n return 1;\n}\n\nexport function two() {\n return 2;\n}\n"; + const crlf = lf.replace(/\n/g, "\r\n"); + assert.deepStrictEqual( + extractSymbols(crlf, ".ts").map((s) => [s.name, s.startLine, s.endLine]), + extractSymbols(lf, ".ts").map((s) => [s.name, s.startLine, s.endLine]) + ); + }); + + test("caps: unsupported ext, oversized content, symbol count", () => { + assert.deepStrictEqual(extractSymbols("function x() {}", ".java"), []); + assert.strictEqual(symbolsSupported(".java"), false); + const big = "x".repeat(SYMBOL_MAX_BYTES + 1); + assert.deepStrictEqual(extractSymbols(big, ".ts"), []); + const many = Array.from({ length: 50 }, (_, i) => `function f${i}() {}`).join("\n"); + assert.strictEqual(extractSymbols(many, ".ts").length, SYMBOL_MAX_COUNT); + }); +}); + +describe("symbol rendering compatibility", () => { + test("sub-bullets are invisible to the legacy entry parser", () => { + const store = newStore(); + store.meta.lastScanned = "t"; + store.files["src/big.ts"] = { + description: "Big module", tokens: 900, updatedAt: "x", source: "hook", + symbols: [ + { name: "alpha", kind: "fn", startLine: 3, endLine: 6, tokens: 120 }, + { name: "Gamma", kind: "class", startLine: 11, endLine: 14, tokens: 300 }, + ], + }; + const md = renderStore(store); + assert.ok(md.includes(" - fn `alpha` L3-6 (~120 tok)")); + const parsed = [...parseAnatomy(md).values()].flat(); + assert.strictEqual(parsed.length, 1, "legacy parser sees exactly the file entry"); + assert.strictEqual(parsed[0].file, "big.ts"); + assert.strictEqual(parsed[0].tokens, 900); + }); +}); diff --git a/tests/token-measurement.test.ts b/tests/token-measurement.test.ts new file mode 100644 index 00000000..439bf4d0 --- /dev/null +++ b/tests/token-measurement.test.ts @@ -0,0 +1,38 @@ +import { test, describe } from "node:test"; +import * as assert from "node:assert"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +// Workstream F1: real token measurement from harness transcripts. + +describe("readTranscriptUsage", () => { + test("sums usage across messages, deduping streamed lines by message id", async () => { + const { readTranscriptUsage } = await import("../src/hooks/shared.ts"); + const f = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "wolf-f1-")), "t.jsonl"); + const lines = [ + { type: "assistant", message: { id: "m1", usage: { input_tokens: 100, output_tokens: 5, cache_read_input_tokens: 800, cache_creation_input_tokens: 20 } } }, + // streamed update for the same message — must replace, not double-count + { type: "assistant", message: { id: "m1", usage: { input_tokens: 100, output_tokens: 42, cache_read_input_tokens: 800, cache_creation_input_tokens: 20 } } }, + { type: "assistant", message: { id: "m2", usage: { input_tokens: 120, output_tokens: 30, cache_read_input_tokens: 900, cache_creation_input_tokens: 0 } } }, + { type: "user", message: { id: "u1" } }, + "not json at all", + ]; + fs.writeFileSync(f, lines.map((l) => (typeof l === "string" ? l : JSON.stringify(l))).join("\n")); + const usage = readTranscriptUsage(f); + assert.ok(usage); + assert.strictEqual(usage.input_tokens, 220); + assert.strictEqual(usage.output_tokens, 72); + assert.strictEqual(usage.cache_read_input_tokens, 1700); + assert.strictEqual(usage.cache_creation_input_tokens, 20); + assert.strictEqual(usage.api_calls, 2); + }); + + test("returns null for missing or usage-free transcripts", async () => { + const { readTranscriptUsage } = await import("../src/hooks/shared.ts"); + assert.strictEqual(readTranscriptUsage("/nonexistent/path.jsonl"), null); + const f = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "wolf-f1-")), "empty.jsonl"); + fs.writeFileSync(f, JSON.stringify({ type: "user", message: {} }) + "\n"); + assert.strictEqual(readTranscriptUsage(f), null); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index c7f68281..7d3f7d15 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,14 @@ "declarationMap": false, "sourceMap": true }, - "include": ["bin/**/*.ts", "src/**/*.ts"], - "exclude": ["node_modules", "dist", "src/dashboard/app"] + "include": [ + "bin/**/*.ts", + "src/**/*.ts" + ], + "exclude": [ + "node_modules", + "dist", + "src/dashboard/app", + "src/templates/opencode-plugin" + ] } From 9a93b4bd18a142220f2c58c304810eefc1caac17 Mon Sep 17 00:00:00 2001 From: Nottyjay Date: Wed, 12 Aug 2026 14:48:24 +0800 Subject: [PATCH 7/9] docs: add Japanese and Russian READMEs with cross-language links Add README.ja.md and README.ru.md, and link English/Chinese/Japanese/Russian on every locale README. Include the new files in the npm package files list. --- README.ja.md | 250 +++++++++++++++++++++++++++++++++++++++++++++++ README.md | 8 +- README.ru.md | 251 ++++++++++++++++++++++++++++++++++++++++++++++++ README.zh-CN.md | 8 +- package.json | 4 +- 5 files changed, 518 insertions(+), 3 deletions(-) create mode 100644 README.ja.md create mode 100644 README.ru.md diff --git a/README.ja.md b/README.ja.md new file mode 100644 index 00000000..7a81aeb3 --- /dev/null +++ b/README.ja.md @@ -0,0 +1,250 @@ +

+ OpenWolf demo +

+ +

@alptech/openwolf

+ +

+ Claude Code のセカンドブレイン。いまやあらゆる AI コーディングアシスタントへ。 +

+ +

+ より良いコンテキスト管理、アーキテクチャ索引、トークン活用を、
+ 7 つの不可視ライフサイクルフックで提供。ワークフロー変更は不要です。 +

+ +

+ npm version + License: AGPL-3.0 + Node.js +

+ +

+ English · + 中文 · + 日本語 · + Русский +

+ +> **本リポジトリは [Cytostack](https://github.com/cytostack) による [openwolf](https://github.com/cytostack/openwolf) のフォークです。** 上流のビジネス機能を追跡しつつ、`@alptech/openwolf` としてのパッケージ識別子を維持します。 + +--- + +| OpenWolf なし | OpenWolf あり | +|---|---| +| すでに読んだファイルを再読込(約 2,000 tokens) | 一行の説明を先に読む、または読込自体をスキップ | +| 1 関数のためにファイル全体を読む | シンボル単位のヒントで正確な行範囲を `offset`/`limit` に渡せる | +| コンテキスト圧縮で作業内容が消える | PreCompact のスナップショットと復元で作業を保持 | +| エージェントごとに冷スタート | Codex / OpenCode / Claude Code / Cursor / Antigravity などで共有する `.wolf/` ブレイン | +| トークンの行き先が分からない | ハーネスのトランスクリプトから実測し、ローカル Dashboard で可視化 | + +--- + +## なぜ OpenWolf か? + +コーディングエージェントは強力ですが、盲目的に動きます。ファイルを開くまで内容が分からず、50 トークンの設定と 2,000 トークンのモジュールを区別できません。同じセッションで同じファイルを再読し、セッションをまたいで修正を忘れ、コンテキスト圧縮で何もかも失います。 + +OpenWolf はそれを直すセカンドブレインです: + +- **コンテキスト管理。** 現在の目標・既知の失敗・修正済みバグ・プロジェクトマップなど、最も価値の高い状態をトークン予算付きでセッション開始時に注入。PreCompact と圧縮対応の再開により、圧縮後も作業が消えません。 +- **アーキテクチャの足場。** 自己修復する永続インデックスが、各ファイルの説明・トークン見積もり、大きなファイルでは関数/クラスと行範囲を保持。エージェントはコードベースを再発見せずにナビゲートできます。 +- **トークン活用。** 重複読込を検知し、全体読込を部分読込へ。ハーネスのトランスクリプトから実使用量を測り、見積もりではなく検証可能な節約を示します。 + +## クイックスタート + +```bash +npm install -g @alptech/openwolf +cd your-project +openwolf init +``` + +これだけです。`init` はマシン上のコーディングエージェントを自動検出し、同じ `.wolf/` ブレインに接続します。普段どおりエージェントを使えば、OpenWolf が裏で動きます。 + +## 対応エージェント + +1 つの `.wolf/` ブレイン、複数エージェント: + +| エージェント | 統合方式 | 深さ | +|--------------|----------|------| +| **Codex CLI** | `.codex/hooks.json` ライフサイクルフック + `AGENTS.md` | 完全(フック + コンテキスト) | +| **OpenCode** | ネイティブプラグイン + `AGENTS.md` | 完全(フック + コンテキスト) | +| **Claude Code** | 7 ライフサイクルフック + `CLAUDE.md` | 完全(フック + コンテキスト) | +| **Cursor** | `.cursor/rules/openwolf.mdc`(常時適用) | Beta(コンテキスト) | +| **Antigravity** | `AGENTS.md` プロトコルブロック | Beta(コンテキスト) | +| **Gemini CLI** | `GEMINI.md` プロトコルブロック | Beta(コンテキスト) | + +```bash +openwolf init # インストール済みエージェントを自動検出(推奨) +openwolf init --agent codex opencode # 指定したものだけ接続 +openwolf init --agent all # 検出可能なすべてを接続 +openwolf init --agent claude # Claude Code のみ +``` + +プロトコルブロックはマーカーで囲まれます。`AGENTS.md` や `GEMINI.md` の自作内容は触れず、`init` の再実行で重複もしません。 + +## 作成されるもの + +`openwolf init` はプロジェクトに `.wolf/` を作成します: + +| ファイル | 用途 | +|----------|------| +| `anatomy-index.json` | 永続プロジェクト索引:説明、トークン見積もり、コンテンツハッシュ、シンボル | +| `anatomy.md` | 索引の人間可読レンダリング(自動同期) | +| `cerebrum.md` | 学習メモリ:好み、訂正、Do-Not-Repeat | +| `memory.md` | 時系列の操作ログとトークン見積もり | +| `STATUS.md` | セッション引き継ぎ:短い読込で再開 | +| `buglog.json` | バグ修正メモリ(検索可能、再発見を防止) | +| `token-ledger.json` | 見積もりと実測のトークン使用量(セッション/エージェント別) | +| `hooks/` | 7 ライフサイクルフック(純粋な Node.js、依存ゼロ) | +| `config.json` | 設定(エージェント別コンテキスト予算を含む) | +| `OPENWOLF.md` | エージェントが従う運用プロトコル | + +## 仕組み + +``` +セッション開始 + | +OpenWolf がトークン予算付きダイジェストを注入:現在の目標、既知の失敗、 +最近のバグ修正、プロジェクトマップへのポインタ + | +エージェントが大きなファイルを読もうとする + | +OpenWolf: "auth.ts (~2,900 tok). Symbols: validateToken L82-140 ~450 tok. +offset/limit で必要な部分だけ読んでください。" + | +エージェントがファイルを編集 + | +OpenWolf がクロスプロセスロック下で索引を更新し、操作を記録、コストを見積もる + | +セッション中にコンテキストが圧縮される + | +OpenWolf が圧縮前に状態をスナップショットし、既に変更したファイルの +ダイジェストを再注入。終わった作業のやり直しを防ぐ + | +セッション終了 + | +OpenWolf がトランスクリプトから実トークン使用量を ledger に記録 +``` + +## コンテキスト管理 + +- **セッションダイジェスト。** セッション開始時に最も価値の高い状態を、エージェントごとに設定可能なトークン予算内で注入。 +- **圧縮サバイバル。** PreCompact が進行中状態をスナップショットし、圧縮後ダイジェストが変更済みファイルとアクションログへのポインタを示す。 +- **鮮度検出。** スキャンは git HEAD をピン留め。HEAD が動いたりスキャンが古くなると、マップを信用する前に再スキャンするよう指示。 +- **STATUS.md 引き継ぎ。** フェーズ終了状態を 1 つの小さな文書に集約し、新しいセッションは 1 回の読込で生産的な文脈に到達。 + +## プロジェクト Anatomy + +索引は `anatomy-index.json` に永続化され、`anatomy.md` として可読表示されます。書き込みはクロスプロセスロックで調整。手書きや古いフック版による markdown 編集はコンテンツハッシュで検出し、加算的に吸収します。 + +見積もり 500 トークン超のファイルはトップレベルシンボルも索引化: + +``` +- `shared.ts` (~3,200 tok) + - fn `parseAnatomy` L82-104 (~180 tok) + - fn `serializeAnatomy` L106-129 (~200 tok) +``` + +大きなファイルを読む前に、最大のシンボルと行範囲を提示し、全体ではなく 1 関数を offset/limit で取得できるようにします。索引後にファイルが変わった場合、ヒントは自動抑制され、古い範囲で誤誘導しません。 +シンボル対応言語:TypeScript、JavaScript、Python、Go、Rust。 + +## トークンインテリジェンス + +見積もりは有用ですが、実測は信頼できます。セッション終了時に OpenWolf はハーネスのトランスクリプトから実使用量(input / output / cache read / cache write / API 呼び出し)を読み、実行したエージェントに帰属させます。 + +```bash +openwolf report +``` + +``` + Estimated (char-ratio heuristic) + Total tokens: 1,549,658 + Est. savings vs bare: 1,772,690 + + Measured (from harness transcripts) + API calls: 29 + Input tokens: 57,489 + Cache reads: 309,141 +``` + +1.x の実地結果(20 プロジェクト、132+ セッション)では平均 65.8% の見積もり削減、繰り返し読込の 71% を捕捉。2.x では自環境のワークロードで実測により節約を検証できます。 + +## セキュリティ + +- Dashboard は 127.0.0.1 にバインドし、API/WebSocket はプロジェクトごとのトークン(タイミングセーフ比較)が必須 +- 動的プロセス起動はすべて引数配列。シェル補間なし +- cron のファイルアクセスはパス・トラバーサル対策(realpath、シンボリックリンク安全) +- 秘密を含むファイル(keys、keystore、credential、`.npmrc`、`.env` など)は索引や memory に入れない +- `pnpm test` でセキュリティ回帰スイートを実行 + +## 同梱 Skills + +`openwolf init` は設定済みエージェント(Claude Code、Codex、OpenCode)に 2 つのスラッシュコマンドを入れます: + +- **`/security-audit [scope]`**:依存関係、秘密、インジェクション面、認可などの多層監査。重大度付きレポートを `.wolf/buglog.json` に連携 +- **`/reframe [migrate | audit | fix]`**:デザイン脳。13 フレームワークの知識ベースで UI 選定/移行、または反ジェネリックなデザイン監査と修正 + +## Dashboard + +```bash +openwolf daemon start +openwolf dashboard +``` + +ローカル・トークン認証のダッシュボード:見積もり vs 実測トークン、キャッシュ経済、エージェント別使用量、コンテキスト健全性、セッション引き継ぎ、ライブ活動、cron 制御、ファイル別シンボル付き anatomy ブラウザ。 + +## コマンド + +``` +openwolf init .wolf/ を初期化し検出したエージェントを接続 +openwolf status 健全性、統計、ファイル整合性 +openwolf scan プロジェクト索引を再構築 +openwolf scan --check 索引がファイルシステムと一致するか検証(CI 向け) +openwolf report トークンレポート:見積もり vs 実測 +openwolf dashboard Web ダッシュボードを開く +openwolf daemon start バックグラウンド daemon を開始 +openwolf daemon stop daemon を停止 +openwolf cron list スケジュール済みタスク +openwolf cron run タスクを手動実行 +openwolf bug search バグメモリを検索 +openwolf update 登録済み全プロジェクトを更新(バックアップ付き) +openwolf restore [backup] タイムスタンプ付きバックアップから .wolf/ をロールバック +``` + +インストール不要のスタンドアロン検査もあります: + +```bash +node scripts/openwolf-check.mjs [projectDir] # 読み取り専用の使用量レポート +``` + +## 要件 + +- Node.js 20+ +- 対応コーディングエージェントが 1 つ以上 +- Windows、macOS、または Linux +- 任意:永続バックグラウンド daemon 用の PM2 + +## 制限 + +- 見積もりは文字比率ヒューリスティック(おおよそ ±15%)。実測はハーネスのトランスクリプト由来で正確 +- フック対応はエージェントにより異なる:Claude Code と Codex はフルライフサイクル、OpenCode はプラグインイベント、Gemini CLI と Cursor はコンテキスト中心 +- プロトコル遵守(cerebrum 更新、バグ記録)はモデルが指示に従うかに依存。フックは強制できる部分を強制し、残りはリマインド +- 不具合は [Issue](https://github.com/nottyjay/openwolf/issues) へ + +## 謝辞 + +本プロジェクトは [Cytostack](https://github.com/cytostack) / Farhan Palathinkal Afsal によるオリジナル +**[OpenWolf](https://github.com/cytostack/openwolf)** を基にしています。 +上流の作者とコントリビューターのアーキテクチャ、フック、継続的な改善に感謝します。 +本フォークは上流のビジネス機能を追跡し、`@alptech/openwolf` として公開します。 + +上流リポジトリ:https://github.com/cytostack/openwolf + +## ライセンス + +[AGPL-3.0](LICENSE) + +## 作者 + +オリジナル:Farhan Palathinkal Afsal — [Cytostack](https://github.com/cytostack)。 +本フォークのメンテナ:`@alptech/openwolf` — [alptech](https://github.com/nottyjay) / [@nottyjay](https://github.com/nottyjay)。 diff --git a/README.md b/README.md index 750fce7d..1d0fa0e8 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,13 @@ npm version License: AGPL-3.0 Node.js - Chinese README +

+ +

+ English · + 中文 · + 日本語 · + Русский

diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 00000000..ae8ec650 --- /dev/null +++ b/README.ru.md @@ -0,0 +1,251 @@ +

+ OpenWolf demo +

+ +

@alptech/openwolf

+ +

+ Второй мозг для Claude Code. Теперь — для любого AI-ассистента программиста. +

+ +

+ Улучшенное управление контекстом, архитектурный индекс и умное использование токенов
+ через 7 невидимых lifecycle-хуков. Без изменений в вашем workflow. +

+ +

+ npm version + License: AGPL-3.0 + Node.js +

+ +

+ English · + 中文 · + 日本語 · + Русский +

+ +> **Это форк [openwolf](https://github.com/cytostack/openwolf) от [Cytostack](https://github.com/cytostack).** Мы синхронизируем бизнес-возможности upstream и сохраняем идентичность пакета `@alptech/openwolf`. + +--- + +| Без OpenWolf | С OpenWolf | +|---|---| +| Агент перечитывает уже виденный файл (~2 000 tokens) | Сначала читает однострочное описание или пропускает чтение | +| Читает весь файл ради одной функции | Подсказки на уровне символов дают точные диапазоны строк для `offset`/`limit` | +| Сжатие контекста стирает работу сессии | PreCompact-снимок и восстановление сохраняют прогресс | +| Каждый агент стартует «с холода» | Один общий мозг `.wolf/` для Codex, OpenCode, Claude Code, Cursor и Antigravity | +| Непонятно, куда ушли токены | Реальное использование из транскриптов harness + локальный Dashboard | + +--- + +## Зачем OpenWolf? + +Кодинг-агенты сильны, но работают вслепую. Агент не знает содержимое файла, пока не откроет его. Не отличает 50-токенный конфиг от модуля на 2 000 токенов. Перечитывает тот же файл в одной сессии, забывает ваши правки между сессиями и теряет всё при сжатии контекста. + +OpenWolf даёт агенту второй мозг: + +- **Управление контекстом.** В начале сессии внедряется дайджест самых ценных данных проекта (цели, известные ошибки, исправленные баги, карта проекта) в пределах бюджета токенов. PreCompact и перезапуск после сжатия не дают стереть сделанную работу. +- **Архитектурные леса.** Самовосстанавливающийся индекс описывает каждый файл, оценивает токены и для крупных файлов хранит функции/классы с точными диапазонами строк. Агенты ориентируются в кодовой базе, а не открывают её заново. +- **Использование токенов.** Повторные чтения ловятся, полные чтения превращаются в срезы, реальное использование измеряется по транскриптам harness — экономию можно проверить, а не только оценить. + +## Быстрый старт + +```bash +npm install -g @alptech/openwolf +cd your-project +openwolf init +``` + +Готово. `init` автоматически находит установленные кодинг-агенты и подключает их к одному мозгу `.wolf/`. Пользуйтесь агентами как обычно — OpenWolf работает «под капотом». + +## Поддерживаемые агенты + +Один мозг `.wolf/`, много агентов: + +| Агент | Интеграция | Глубина | +|-------|------------|---------| +| **Codex CLI** | lifecycle-хуки `.codex/hooks.json` + `AGENTS.md` | Полная (хуки + контекст) | +| **OpenCode** | Нативный плагин + `AGENTS.md` | Полная (хуки + контекст) | +| **Claude Code** | 7 lifecycle-хуков + `CLAUDE.md` | Полная (хуки + контекст) | +| **Cursor** | `.cursor/rules/openwolf.mdc` (всегда применяется) | Beta (контекст) | +| **Antigravity** | Протокольный блок в `AGENTS.md` | Beta (контекст) | +| **Gemini CLI** | Протокольный блок в `GEMINI.md` | Beta (контекст) | + +```bash +openwolf init # автоопределение установленных агентов (рекомендуется) +openwolf init --agent codex opencode # подключить только указанные +openwolf init --agent all # подключить все обнаруженные +openwolf init --agent claude # только Claude Code +``` + +Протокольные блоки огорожены маркерами: ваш собственный текст в `AGENTS.md` или `GEMINI.md` не трогается, повторный `init` ничего не дублирует. + +## Что создаётся + +`openwolf init` создаёт каталог `.wolf/` в проекте: + +| Файл | Назначение | +|------|------------| +| `anatomy-index.json` | Устойчивый индекс: описания, оценка токенов, хеши содержимого, символы | +| `anatomy.md` | Человекочитаемый рендер индекса, синхронизируется автоматически | +| `cerebrum.md` | Память обучения: предпочтения, правки, список Do-Not-Repeat | +| `memory.md` | Хронологический журнал действий с оценкой токенов | +| `STATUS.md` | Передача сессии: возобновление одним коротким чтением | +| `buglog.json` | Память исправлений багов, с поиском, без повторного «открытия» | +| `token-ledger.json` | Оценённое и измеренное использование токенов (по сессиям и агентам) | +| `hooks/` | 7 lifecycle-хуков (чистый Node.js, без зависимостей) | +| `config.json` | Конфигурация, включая бюджеты контекста по агентам | +| `OPENWOLF.md` | Операционный протокол, которому следуют агенты | + +## Как это работает + +``` +Сессия начинается + | +OpenWolf внедряет дайджест в бюджете токенов: текущие цели, известные ошибки, +недавние фиксы, указатель на карту проекта + | +Агент решает прочитать большой файл + | +OpenWolf: "auth.ts (~2,900 tok). Symbols: validateToken L82-140 ~450 tok. +Читайте offset/limit — только нужный фрагмент." + | +Агент правит файлы + | +OpenWolf обновляет индекс под межпроцессной блокировкой, логирует действие, +оценивает стоимость + | +Контекст сжимается посреди сессии + | +OpenWolf делает снимок до сжатия и снова внедряет дайджест уже изменённых +файлов, чтобы агент не переделывал готовое + | +Сессия заканчивается + | +OpenWolf читает реальное использование токенов из транскрипта в ledger +``` + +## Управление контекстом + +- **Дайджест сессии.** Самое ценное состояние попадает в контекст модели в начале сессии в пределах настраиваемого бюджета токенов на агента. +- **Выживание при сжатии.** Хук PreCompact снимает состояние «на лету»; после сжатия дайджест перечисляет уже изменённые файлы и указывает на журнал действий. +- **Обнаружение устаревания.** Сканы закрепляют git HEAD. Если HEAD сдвинулся или скан устарел, агенту говорят пересканировать карту до доверия к ней. +- **Передача через STATUS.md.** Состояние конца фазы живёт в одном маленьком документе — новая сессия выходит на продуктивный контекст одним чтением. + +## Anatomy проекта + +Индекс — устойчивое хранилище (`anatomy-index.json`) с читаемым видом (`anatomy.md`). Запись координируется межпроцессной блокировкой. Правки markdown вручную или старыми хуками обнаруживаются по хешу содержимого и поглощаются аддитивно. + +Файлы свыше ~500 токенов также индексируют top-level символы: + +``` +- `shared.ts` (~3,200 tok) + - fn `parseAnatomy` L82-104 (~180 tok) + - fn `serializeAnatomy` L106-129 (~200 tok) +``` + +Перед чтением большого файла подсказка перечисляет крупнейшие символы с диапазонами строк, чтобы агент мог взять одну функцию через offset/limit. Если файл изменился после индексации, подсказки подавляются — устаревший диапазон не вводит в заблуждение. +Языки с поддержкой символов: TypeScript, JavaScript, Python, Go, Rust. + +## Интеллект токенов + +Оценки полезны; измерения — надёжны. В конце сессии OpenWolf читает реальное использование из транскрипта harness: input, output, cache read, cache write и число API-вызовов, с привязкой к агенту. + +```bash +openwolf report +``` + +``` + Estimated (char-ratio heuristic) + Total tokens: 1,549,658 + Est. savings vs bare: 1,772,690 + + Measured (from harness transcripts) + API calls: 29 + Input tokens: 57,489 + Cache reads: 309,141 +``` + +Полевые результаты 1.x (20 проектов, 132+ сессий): в среднем ~65,8% оценённого сокращения токенов, 71% повторных чтений перехвачены. В 2.x измеренные цифры позволяют проверить экономию на вашей нагрузке. + +## Безопасность + +- Dashboard слушает 127.0.0.1 и требует per-project token (сравнение с защитой от timing-атак) для API и WebSocket +- Любой динамический запуск процессов — через массивы аргументов, без shell-интерполяции +- Защита от path traversal для доступа cron к файлам (realpath, безопасно для symlink) +- Файлы с секретами (keys, keystore, credentials, `.npmrc`, `.env` и т.п.) не попадают в индекс и memory +- Регрессионный security-набор: `pnpm test` + +## Встроенные Skills + +`openwolf init` ставит две slash-команды во все настроенные агенты (Claude Code, Codex, OpenCode): + +- **`/security-audit [scope]`**: многослойный аудит зависимостей, секретов, injection-поверхностей и авторизации; отчёт по severity пишется в `.wolf/buglog.json` +- **`/reframe [migrate | audit | fix]`**: «дизайн-мозг». Выбор/миграция UI-фреймворка по базе из 13 фреймворков или аудит/фикс UI против «generic AI look» + +## Dashboard + +```bash +openwolf daemon start +openwolf dashboard +``` + +Локальная панель с token-аутентификацией: оценка vs измерение токенов, экономика кэша, использование по агентам, здоровье контекста, handoff сессии, live-активность, управление cron и полный anatomy-браузер с символами по файлам. + +## Команды + +``` +openwolf init Инициализировать .wolf/ и подключить найденных агентов +openwolf status Здоровье, статистика, целостность файлов +openwolf scan Пересобрать индекс проекта +openwolf scan --check Проверить соответствие индекса ФС (удобно для CI) +openwolf report Отчёт по токенам: оценка vs измерение +openwolf dashboard Открыть веб-дашборд +openwolf daemon start Запустить фоновый daemon +openwolf daemon stop Остановить daemon +openwolf cron list Список задач по расписанию +openwolf cron run Запустить задачу вручную +openwolf bug search Поиск в памяти багов +openwolf update Обновить все зарегистрированные проекты (с бэкапом) +openwolf restore [backup] Откатить .wolf/ из timestamp-бэкапа +``` + +Есть и автономный инспектор без установки пакета: + +```bash +node scripts/openwolf-check.mjs [projectDir] # только чтение, отчёт об использовании +``` + +## Требования + +- Node.js 20+ +- Хотя бы один поддерживаемый кодинг-агент +- Windows, macOS или Linux +- Опционально: PM2 для постоянного фонового daemon + +## Ограничения + +- Оценки — эвристика по соотношению символов (примерно ±15%); измерения из транскриптов harness точны +- Покрытие хуками зависит от агента: Claude Code и Codex — полный lifecycle, OpenCode — события плагина, Gemini CLI и Cursor — в основном контекст +- Соблюдение протокола (обновление cerebrum, запись багов) зависит от следования модели инструкциям; хуки принуждают к тому, что можно принудить, и напоминают об остальном +- Нашли баг? [Создайте issue](https://github.com/nottyjay/openwolf/issues) + +## Благодарности + +Проект основан на оригинальном **[OpenWolf](https://github.com/cytostack/openwolf)** +от [Cytostack](https://github.com/cytostack) / Farhan Palathinkal Afsal. +Спасибо авторам и контрибьюторам upstream за архитектуру, хуки и развитие. +Этот форк синхронизирует бизнес-функции upstream и публикуется как `@alptech/openwolf`. + +Репозиторий upstream: https://github.com/cytostack/openwolf + +## Лицензия + +[AGPL-3.0](LICENSE) + +## Автор + +Оригинал: Farhan Palathinkal Afsal — [Cytostack](https://github.com/cytostack). +Поддержка форка `@alptech/openwolf`: [alptech](https://github.com/nottyjay) / [@nottyjay](https://github.com/nottyjay). diff --git a/README.zh-CN.md b/README.zh-CN.md index 3243efc2..43c07b07 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -17,7 +17,13 @@ npm version License: AGPL-3.0 Node.js - English README +

+ +

+ English · + 中文 · + 日本語 · + Русский

> **本仓库是 [openwolf](https://github.com/cytostack/openwolf)([Cytostack](https://github.com/cytostack))的 fork。** 同步上游业务能力,并保留 `@alptech/openwolf` 包名与项目身份。 diff --git a/package.json b/package.json index 4e7cf6a4..b7eee937 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,8 @@ "src/templates/", "LICENSE", "README.md", - "README.zh-CN.md" + "README.zh-CN.md", + "README.ja.md", + "README.ru.md" ] } From 9ff5e2d174d417895f17b64c02161517e425aff3 Mon Sep 17 00:00:00 2001 From: Nottyjay Date: Wed, 12 Aug 2026 15:42:12 +0800 Subject: [PATCH 8/9] 1.0.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b7eee937..8d45fafb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@alptech/openwolf", - "version": "1.0.6", + "version": "1.0.7", "description": "The second brain for Claude Code, now for every AI coding assistant: context management, architecture scaffolding, and smarter token utilization through lifecycle hooks.", "type": "module", "bin": { From 200dc56f7e4338b63a51ba550fa1f04a31f36a16 Mon Sep 17 00:00:00 2001 From: Nottyjay Date: Thu, 13 Aug 2026 14:30:11 +0800 Subject: [PATCH 9/9] 1.1.0 --- docs/commands.md | 2 +- docs/designqc.md | 37 +++---- docs/how-it-works.md | 2 +- package.json | 2 +- src/cli/designqc-browser.ts | 165 ++++++++++++++++++++++++++++++ src/cli/designqc-routes.ts | 64 ++++++++++++ src/cli/designqc-server.ts | 101 +++++++++++++++++++ src/cli/designqc.ts | 194 ++++++++++++++++++++++++++++++++++++ src/cli/index.ts | 13 +++ src/templates/OPENWOLF.md | 2 + src/templates/config.json | 9 ++ 11 files changed, 571 insertions(+), 20 deletions(-) create mode 100644 src/cli/designqc-browser.ts create mode 100644 src/cli/designqc-routes.ts create mode 100644 src/cli/designqc-server.ts create mode 100644 src/cli/designqc.ts diff --git a/docs/commands.md b/docs/commands.md index e7c0151b..743eedd4 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -282,7 +282,7 @@ openwolf restore 2026-03-15T10-30-00 Capture full-page screenshots for design evaluation by Claude Code. ```bash -openwolf designqc [target] +openwolf designqc ``` **Options:** diff --git a/docs/designqc.md b/docs/designqc.md index 0a395538..7c905d28 100644 --- a/docs/designqc.md +++ b/docs/designqc.md @@ -49,8 +49,8 @@ For each route, Design QC: 1. Opens the page in a headless Chromium instance 2. Scrolls through the full page height 3. Takes a viewport-height JPEG at each scroll position (one per "fold") -4. Captures up to **8 sections** per route -5. Repeats for both desktop (1200px) and mobile (390px) viewports by default +4. Captures up to `designqc.max_screenshots` sections per route (default 6) +5. Repeats for both desktop (1440px) and mobile (375px) viewports by default Screenshots are saved as JPEG at quality 70, max width 1200px -- optimized for token economy at roughly **2,500 tokens per screenshot**. @@ -112,28 +112,31 @@ Design QC settings can be configured in `.wolf/config.json` under the `designqc` ## Chrome Detection -OpenWolf searches for a Chromium-based browser in this order: +OpenWolf searches for a Chromium-based browser in this order. The OS default browser is checked first — if it's Chromium-based (Chrome, Edge, Brave, etc.), it's used directly; non-Chromium defaults (e.g. Firefox) are skipped. **Windows:** 1. `designqc.chromePath` in `.wolf/config.json` (manual override) -2. `Program Files/Google/Chrome/Application/chrome.exe` -3. `Program Files (x86)/Google/Chrome/Application/chrome.exe` -4. `Program Files/Microsoft/Edge/Application/msedge.exe` -5. `where chrome` (PATH lookup) -6. `where msedge` (PATH lookup) +2. System default browser (registry `UserChoice` ProgId) — only if Chromium-based +3. `Program Files/Google/Chrome/Application/chrome.exe` +4. `Program Files (x86)/Google/Chrome/Application/chrome.exe` +5. `Program Files/Microsoft/Edge/Application/msedge.exe` +6. `Program Files (x86)/Microsoft/Edge/Application/msedge.exe` +7. `where chrome` / `where msedge` (PATH lookup) **macOS:** 1. Config override -2. `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` -3. `/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge` +2. System default browser (LaunchServices) — only if Chromium-based +3. `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` +4. `/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge` **Linux:** 1. Config override -2. `which google-chrome` -3. `which google-chrome-stable` -4. `which chromium-browser` -5. `which chromium` -6. `which microsoft-edge` +2. System default browser (`xdg-settings`) — only if Chromium-based +3. `which google-chrome` +4. `which google-chrome-stable` +5. `which chromium-browser` +6. `which chromium` +7. `which microsoft-edge` If no browser is found, Design QC exits with an error and instructions to set the path manually in config. @@ -147,10 +150,10 @@ The math for a single route with default settings: | Factor | Value | |--------|-------| -| Sections per route | Up to 8 | +| Sections per route | Up to 6 (default `max_screenshots`) | | Viewports | 2 (desktop + mobile) | | Tokens per screenshot | ~2,500 | -| **Max per route** | **~40,000 tokens** | +| **Max per route** | **~30,000 tokens** | For a site with 5 detected routes, that is up to 200K tokens per full capture. To reduce cost: diff --git a/docs/how-it-works.md b/docs/how-it-works.md index d8bd5709..d0eb69e7 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -84,7 +84,7 @@ Design QC is a capture-only tool. It takes screenshots; Claude does the evaluati ### How it works -1. **Dev server detection** -- `openwolf designqc` checks common ports (3000, 5173, 4321, 8080) for a running dev server. If none is found, it starts one automatically using `npm run dev`, `pnpm dev`, or whatever start script your project defines. +1. **Dev server detection** -- `openwolf designqc` checks common ports (3000, 3001, 5173, 5174, 4321, 8080, 8000, 4200) for a running dev server. If none is found, it starts one automatically using `npm run dev`, `pnpm dev`, or whatever start script your project defines. 2. **Route detection** -- OpenWolf scans your project for route files (Next.js `app/` routes, file-based routers, etc.) and builds a list of pages to capture. You can also specify routes manually with `--routes`. diff --git a/package.json b/package.json index 8d45fafb..3cd134d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@alptech/openwolf", - "version": "1.0.7", + "version": "1.1.0", "description": "The second brain for Claude Code, now for every AI coding assistant: context management, architecture scaffolding, and smarter token utilization through lifecycle hooks.", "type": "module", "bin": { diff --git a/src/cli/designqc-browser.ts b/src/cli/designqc-browser.ts new file mode 100644 index 00000000..a044d7ff --- /dev/null +++ b/src/cli/designqc-browser.ts @@ -0,0 +1,165 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { execFileSync } from "node:child_process"; + +export interface DetectedBrowser { + path: string; + name: string; + source: "config" | "default-browser" | "known-path" | "path"; +} + +const CHROMIUM_RE = /(chrome|chromium|msedge|edge)/i; + +function isChromiumName(candidate: string): boolean { + return CHROMIUM_RE.test(candidate); +} + +function isChromiumExecutable(exePath: string): boolean { + if (!isChromiumName(path.basename(exePath))) return false; + return isChromiumName(path.dirname(exePath)); +} + +function exists(p: string): boolean { + try { + return fs.existsSync(p); + } catch { + return false; + } +} + +function run(cmd: string, args: string[]): string { + try { + return execFileSync(cmd, args, { encoding: "utf8", windowsHide: true, timeout: 5000 }); + } catch { + return ""; + } +} + +function parseWindowsCommand(regValue: string): string | null { + const raw = regValue.trim(); + const quoted = raw.match(/^"([^"]+)"/); + let exePath: string | null = null; + if (quoted) { + exePath = quoted[1]; + } else { + const first = raw.split(/\s+/)[0]; + if (first && !/rundll32/i.test(first)) exePath = first; + } + if (!exePath || !isChromiumExecutable(exePath) || !exists(exePath)) return null; + return exePath; +} + +function windowsDefaultBrowser(): string | null { + const userChoice = run("reg", [ + "query", + `HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\http\\UserChoice`, + "/v", + "ProgId", + ]); + const m = userChoice.match(/ProgId\s+REG_\w+\s+(\S+)/); + if (!m) return null; + const progId = m[1].trim(); + if (!isChromiumName(progId)) return null; + const command = run("reg", ["query", `HKEY_CLASSES_ROOT\\${progId}\\shell\\open\\command`, "/ve"]); + const cm = command.match(/REG_\w+\s+(.+)$/m); + if (!cm) return null; + return parseWindowsCommand(cm[1]); +} + +function macDefaultBrowser(): string | null { + const out = run("defaults", ["read", "com.apple.LaunchServices/com.apple.launchservices.secure", "LSHandlers"]); + const bundles = ["com.google.Chrome", "com.microsoft.edgemac", "com.brave.Browser", "org.chromium.Chromium"]; + const exeNames: Record = { + "com.google.Chrome": "Google Chrome", + "com.microsoft.edgemac": "Microsoft Edge", + "com.brave.Browser": "Brave Browser", + "org.chromium.Chromium": "Chromium", + }; + for (const bundle of bundles) { + if (!out.includes(bundle)) continue; + const found = run("mdfind", [`kMDItemCFBundleIdentifier == "${bundle}"`]).split("\n")[0].trim(); + if (!found) continue; + const appPath = path.join(found, "Contents", "MacOS", exeNames[bundle]); + if (exists(appPath)) return appPath; + } + return null; +} + +function linuxDefaultBrowser(): string | null { + const out = run("xdg-settings", ["get", "default-web-browser"]).trim(); + if (!out || !isChromiumName(out)) return null; + const candidates = [ + out.replace(/\.desktop$/, ""), + out.replace(/[^a-z]/gi, "").toLowerCase(), + ]; + for (const c of candidates) { + const p = run("which", [c]).split("\n")[0].trim(); + if (p && exists(p)) return p; + } + return null; +} + +function knownPaths(): string[] { + const paths: string[] = []; + if (process.platform === "win32") { + const dirs = [process.env.ProgramFiles, process.env["ProgramFiles(x86)"]].filter(Boolean) as string[]; + for (const d of dirs) { + paths.push(path.join(d, "Google", "Chrome", "Application", "chrome.exe")); + paths.push(path.join(d, "Microsoft", "Edge", "Application", "msedge.exe")); + } + } else if (process.platform === "darwin") { + paths.push("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"); + paths.push("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"); + } else { + paths.push( + "/usr/bin/google-chrome", + "/usr/bin/google-chrome-stable", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/usr/bin/microsoft-edge", + "/snap/bin/chromium" + ); + } + return paths.filter(exists); +} + +function pathLookup(): string | null { + const cmd = + process.platform === "win32" ? "where" : process.platform === "darwin" ? "which" : "which"; + const names = + process.platform === "win32" + ? ["chrome", "msedge", "chromium"] + : ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"]; + for (const name of names) { + const p = run(cmd, [name]).split(/\r?\n/)[0].trim(); + if (p && exists(p)) return p; + } + return null; +} + +/** + * Find a usable Chromium-based browser for headless capture. + * Priority: config chrome_path > OS default browser > known install paths > PATH. + */ +export function detectBrowser(chromePath?: string | null): DetectedBrowser | null { + if (chromePath) { + if (exists(chromePath)) { + return { path: chromePath, name: path.basename(chromePath), source: "config" }; + } + } + + let p: string | null = null; + if (process.platform === "win32") p = windowsDefaultBrowser(); + else if (process.platform === "darwin") p = macDefaultBrowser(); + else p = linuxDefaultBrowser(); + if (p) return { path: p, name: path.basename(p), source: "default-browser" }; + + for (const kp of knownPaths()) { + return { path: kp, name: path.basename(kp), source: "known-path" }; + } + + const lp = pathLookup(); + if (lp) return { path: lp, name: path.basename(lp), source: "path" }; + + return null; +} diff --git a/src/cli/designqc-routes.ts b/src/cli/designqc-routes.ts new file mode 100644 index 00000000..2301b52e --- /dev/null +++ b/src/cli/designqc-routes.ts @@ -0,0 +1,64 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +const PAGE_EXTENSIONS = [".tsx", ".jsx", ".ts", ".js", ".vue", ".astro", ".svelte"]; +const ROUTE_DIRS = ["src/pages", "pages", "src/app", "app"]; +const INDEX_NAMES = new Set(["index", "index.jsx", "index.tsx", "index.js", "index.ts", "index.vue", "index.astro", "index.svelte"]); +const IGNORED_SEGMENTS = new Set(["_app", "_document", "_error", "api", "layout"]); + +function segmentIsDynamic(seg: string): boolean { + return seg.startsWith("[") && seg.endsWith("]"); +} + +/** + * File-system route detection for common frameworks. + * - Next.js: `app/` and `pages/` directories + * - Vite / React Router: `pages/` directory + * - Astro: `src/pages/` directory + * Dynamic segments (`[id]`) and api/layout helpers are skipped. + */ +export function detectRoutes(projectRoot: string): string[] { + const routes = new Set(["/"]); + + for (const rel of ROUTE_DIRS) { + const dir = path.join(projectRoot, rel); + if (!fs.existsSync(dir)) continue; + walk(projectRoot, dir, rel, routes); + } + + return [...routes].sort(); +} + +function walk(projectRoot: string, dir: string, relBase: string, routes: Set): void { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (entry.name.startsWith(".")) continue; + const full = path.join(dir, entry.name); + + if (entry.isDirectory()) { + if (IGNORED_SEGMENTS.has(entry.name) || segmentIsDynamic(entry.name)) continue; + walk(projectRoot, full, relBase, routes); + continue; + } + + const ext = path.extname(entry.name); + if (!PAGE_EXTENSIONS.includes(ext)) continue; + if (!/^page\.|^index\./.test(entry.name)) continue; + + const rel = path.relative(projectRoot, full); + const relDir = path.dirname(rel).split(/[\\/]/); + const baseIndex = relDir.findIndex(seg => seg === relBase.split(/[\\/]/)[0]); + const fromBase = baseIndex === -1 ? [] : relDir.slice(baseIndex + 1).filter(seg => !IGNORED_SEGMENTS.has(seg) && !segmentIsDynamic(seg)); + + const isIndex = /^index\./.test(entry.name); + const segments = isIndex ? fromBase : [...fromBase, entry.name.replace(ext, "")]; + const route = "/" + segments.filter(Boolean).join("/"); + routes.add(route === "/" ? "/" : route.replace(/\/+$/, "")); + } +} diff --git a/src/cli/designqc-server.ts b/src/cli/designqc-server.ts new file mode 100644 index 00000000..b5eb0724 --- /dev/null +++ b/src/cli/designqc-server.ts @@ -0,0 +1,101 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as net from "node:net"; +import { spawn, type ChildProcess } from "node:child_process"; + +const PROBE_PORTS = [3000, 3001, 5173, 5174, 4321, 8080, 8000, 4200]; +const STARTUP_TIMEOUT_MS = 60_000; +const PORT_POLL_INTERVAL_MS = 500; + +export interface StartedServer { + url: string; + child: ChildProcess | null; +} + +function isPortOpen(port: number, host = "127.0.0.1"): Promise { + return new Promise(resolve => { + const sock = net.connect({ port, host }); + sock.setTimeout(1500); + sock.once("connect", () => { + sock.destroy(); + resolve(true); + }); + sock.once("error", () => resolve(false)); + sock.once("timeout", () => { + sock.destroy(); + resolve(false); + }); + }); +} + +async function waitForPort(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await isPortOpen(port)) return true; + await new Promise(r => setTimeout(r, PORT_POLL_INTERVAL_MS)); + } + return false; +} + +/** Probe known dev ports; return the base URL of the first responder, if any. */ +export async function detectRunningServer(): Promise { + for (const port of PROBE_PORTS) { + if (await isPortOpen(port)) return `http://localhost:${port}`; + } + return null; +} + +function detectPackageManager(projectRoot: string): string { + if (fs.existsSync(path.join(projectRoot, "pnpm-lock.yaml"))) return "pnpm"; + if (fs.existsSync(path.join(projectRoot, "bun.lockb")) || fs.existsSync(path.join(projectRoot, "bun.lock"))) return "bun"; + if (fs.existsSync(path.join(projectRoot, "yarn.lock"))) return "yarn"; + return "npm"; +} + +function findStartScript(projectRoot: string): string | null { + try { + const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf-8")); + const scripts: Record = pkg.scripts ?? {}; + for (const name of ["dev", "start", "serve"]) { + if (typeof scripts[name] === "string" && scripts[name].trim()) return name; + } + } catch { + // Not a Node project — caller reports the error. + } + return null; +} + +/** Start the project's dev server and wait for it to accept connections. */ +export async function startDevServer(projectRoot: string): Promise { + const script = findStartScript(projectRoot); + if (!script) { + throw new Error("No running server found and no dev/start/serve script in package.json"); + } + + const pm = detectPackageManager(projectRoot); + const bin = process.platform === "win32" ? `${pm}.cmd` : pm; + const port = PROBE_PORTS[0]; + const url = `http://localhost:${port}`; + + console.log(` ✓ Starting dev server: ${pm} ${script} (on port ${port})`); + const child = spawn(bin, ["run", script], { + cwd: projectRoot, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + env: { ...process.env, PORT: String(port) }, + }); + + child.stdout?.on("data", d => process.stdout.write(d)); + child.stderr?.on("data", d => process.stderr.write(d)); + child.on("exit", code => { + if (code !== 0) process.stderr.write(`\n[designqc] dev server exited with code ${code}\n`); + }); + + const ok = await waitForPort(port, STARTUP_TIMEOUT_MS); + if (!ok) { + child.kill(); + throw new Error(`Dev server did not respond on port ${port} within ${STARTUP_TIMEOUT_MS / 1000}s`); + } + + return { url, child }; +} diff --git a/src/cli/designqc.ts b/src/cli/designqc.ts new file mode 100644 index 00000000..543a2b19 --- /dev/null +++ b/src/cli/designqc.ts @@ -0,0 +1,194 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { findProjectRoot } from "../scanner/project-root.js"; +import { readJSON, writeJSON } from "../utils/fs-safe.js"; +import { detectBrowser } from "./designqc-browser.js"; +import { detectRunningServer, startDevServer, type StartedServer } from "./designqc-server.js"; +import { detectRoutes } from "./designqc-routes.js"; + +interface DesignQcConfig { + enabled: boolean; + viewports: { name: string; width: number; height: number }[]; + max_screenshots: number; + chrome_path: string | null; +} + +const DEFAULT_CONFIG: DesignQcConfig = { + enabled: true, + viewports: [ + { name: "desktop", width: 1440, height: 900 }, + { name: "mobile", width: 375, height: 812 }, + ], + max_screenshots: 6, + chrome_path: null, +}; + +export interface DesignQcOptions { + url?: string; + routes?: string[]; + desktopOnly?: boolean; + quality?: string; + maxWidth?: string; +} + +interface Capture { + file: string; + route: string; + viewport: string; + fold: number; + bytes: number; +} + +function slugify(p: string): string { + const s = p + .replace(/[/\\:*?"<>|]/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + return s || "index"; +} + +function wait(ms: number): Promise { + return new Promise(r => setTimeout(r, ms)); +} + +async function captureRoute( + browser: import("puppeteer-core").Browser, + baseUrl: string, + viewports: DesignQcConfig["viewports"], + maxScreenshots: number, + quality: number, + dir: string +): Promise { + const route = slugify(new URL(baseUrl).pathname); + const captures: Capture[] = []; + + for (const vp of viewports) { + const page = await browser.newPage(); + await page.setViewport({ width: vp.width, height: vp.height }); + await page + .goto(baseUrl, { waitUntil: "networkidle2", timeout: 30000 }) + .catch(() => page.goto(baseUrl, { waitUntil: "load", timeout: 30000 })); + await page.evaluate(() => (document as Document).fonts?.ready).catch(() => {}); + + const fullHeight = await page.evaluate(() => document.documentElement.scrollHeight); + let y = 0; + let fold = 0; + while (y < fullHeight && fold < maxScreenshots) { + await page.evaluate((yy: number) => window.scrollTo(0, yy), y); + await wait(200); + const file = `${route}__${vp.name}__${String(fold + 1).padStart(2, "0")}.jpg`; + const filePath = path.join(dir, file); + await page.screenshot({ path: filePath, type: "jpeg", quality }); + captures.push({ file, route, viewport: vp.name, fold: fold + 1, bytes: fs.statSync(filePath).size }); + y += vp.height; + fold++; + } + await page.close(); + } + + return captures; +} + +export async function designqcCommand(options: DesignQcOptions): Promise { + const projectRoot = findProjectRoot(); + const wolfDir = path.join(projectRoot, ".wolf"); + if (!fs.existsSync(wolfDir)) { + console.error("OpenWolf not initialized. Run: openwolf init"); + process.exit(1); + } + + const loaded = readJSON<{ designqc?: Partial }>(path.join(wolfDir, "config.json"), { designqc: {} }); + const cfg: DesignQcConfig = { ...DEFAULT_CONFIG, ...(loaded.designqc ?? {}) }; + if (!cfg.viewports?.length) cfg.viewports = DEFAULT_CONFIG.viewports; + if (!cfg.enabled) { + console.log("designqc is disabled in .wolf/config.json"); + return; + } + + let puppeteer: typeof import("puppeteer-core"); + try { + puppeteer = (await import("puppeteer-core")).default; + } catch { + console.error("puppeteer-core is required for designqc. Install it with: npm install puppeteer-core"); + process.exit(1); + } + + const browserInfo = detectBrowser(cfg.chrome_path); + if (!browserInfo) { + console.error( + "No Chromium-based browser found (Chrome/Edge). Set designqc.chrome_path in .wolf/config.json" + ); + process.exit(1); + } + + let server: StartedServer = { url: options.url ?? "", child: null }; + if (!options.url) { + const running = await detectRunningServer(); + if (running) { + console.log(` ✓ Found running dev server: ${running}`); + server = { url: running, child: null }; + } else { + try { + server = await startDevServer(projectRoot); + } catch (err) { + console.error(` ✗ ${(err as Error).message}`); + console.error(" Pass --url to capture an already-running server."); + process.exit(1); + } + } + } + + const routes = options.routes?.length ? options.routes : detectRoutes(projectRoot); + console.log(` ✓ Browser: ${browserInfo.name} (${browserInfo.source})`); + console.log(` ✓ Routes (${routes.length}): ${routes.join(" ")}`); + + const quality = Math.min(100, Math.max(1, parseInt(options.quality ?? "70", 10) || 70)); + let viewports = options.desktopOnly ? [cfg.viewports[0]] : cfg.viewports; + const maxWidth = parseInt(options.maxWidth ?? "", 10); + if (maxWidth > 0) { + viewports = viewports.map(v => ({ ...v, width: Math.min(v.width, maxWidth) })); + } + + const capturesDir = path.join(wolfDir, "designqc-captures"); + fs.mkdirSync(capturesDir, { recursive: true }); + + let browser: import("puppeteer-core").Browser | undefined; + let allCaptures: Capture[] = []; + try { + browser = await puppeteer.launch({ + executablePath: browserInfo.path, + headless: true, + args: ["--no-sandbox", "--disable-gpu"], + }); + + for (const route of routes) { + const url = new URL(route, server.url).toString(); + allCaptures = allCaptures.concat( + await captureRoute(browser, url, viewports, cfg.max_screenshots, quality, capturesDir) + ); + } + + const report = { + generated_at: new Date().toISOString(), + server: server.url, + browser: browserInfo.name, + routes, + viewports: viewports.map(v => `${v.name} ${v.width}x${v.height}`), + captures: allCaptures, + tokens_estimated: allCaptures.length * 2500, + }; + writeJSON(path.join(wolfDir, "designqc-report.json"), report); + + console.log(` ✓ Captured ${allCaptures.length} screenshots`); + for (const c of allCaptures) { + console.log(` - ${c.file} (${(c.bytes / 1024).toFixed(1)} KB)`); + } + console.log(` ✓ Saved to .wolf/designqc-captures/ (~${report.tokens_estimated.toLocaleString()} tokens estimated)`); + } finally { + if (browser) await browser.close(); + if (server.child) { + server.child.kill(); + console.log(" ✓ Stopped auto-started dev server"); + } + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 210b0389..2c0430b9 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -59,6 +59,19 @@ export function createProgram(): Command { .description("Token report: estimated vs measured (from harness transcripts)") .action(reportCommand); + program + .command("designqc") + .description("Capture screenshots of a running app for design evaluation") + .option("--url ", "Dev server URL. Auto-detects a running server, or starts one, if omitted") + .option("--routes ", "Specific routes to capture instead of auto-detecting") + .option("--desktop-only", "Skip the mobile viewport") + .option("--quality ", "JPEG quality (1-100), default 70") + .option("--max-width ", "Maximum capture width in pixels") + .action(async (opts: { url?: string; routes?: string[]; desktopOnly?: boolean; quality?: string; maxWidth?: string }) => { + const { designqcCommand } = await import("./designqc.js"); + await designqcCommand(opts); + }); + const daemon = program .command("daemon") .description("Daemon management"); diff --git a/src/templates/OPENWOLF.md b/src/templates/OPENWOLF.md index 4af783cd..3cdff83a 100644 --- a/src/templates/OPENWOLF.md +++ b/src/templates/OPENWOLF.md @@ -114,6 +114,8 @@ OpenWolf's value comes from learning across sessions. You MUST update `.wolf/cer ## Design QC +> **Vision gate:** If your model cannot read images (no vision support), IGNORE everything below — do not run `openwolf designqc`, do not read `.wolf/designqc-captures/`, and never claim to have seen screenshots. Just tell the user vision is unavailable and offer text-based review instead. + When the user asks you to check, evaluate, or improve the design/UI of their app: 1. Run `openwolf designqc` via Bash to capture screenshots. diff --git a/src/templates/config.json b/src/templates/config.json index 3a267872..180c4fe7 100644 --- a/src/templates/config.json +++ b/src/templates/config.json @@ -71,5 +71,14 @@ "cursor": 800 } } + }, + "designqc": { + "enabled": true, + "viewports": [ + { "name": "desktop", "width": 1440, "height": 900 }, + { "name": "mobile", "width": 375, "height": 812 } + ], + "max_screenshots": 6, + "chrome_path": null } }