diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 49f6d8e..f4ef9a5 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -72,6 +72,11 @@ "name": "pstack", "source": "pstack", "description": "if you want to go fast, go deep first. pstack helps you write less, but higher quality code. rigorous agent workflows you can parallelize with confidence." + }, + { + "name": "evolver", + "source": "evolver", + "description": "Persistent, auditable evolution memory for the agent: recalls what worked on past tasks at session start, detects improvement signals while you edit, and records outcomes when a task ends. Powered by the Genome Evolution Protocol (GEP)." } ] } diff --git a/evolver/.cursor-plugin/plugin.json b/evolver/.cursor-plugin/plugin.json new file mode 100644 index 0000000..1a1c88a --- /dev/null +++ b/evolver/.cursor-plugin/plugin.json @@ -0,0 +1,33 @@ +{ + "name": "evolver", + "displayName": "Evolver — Self-Evolving Agent Memory", + "description": "Gives the agent a persistent, auditable evolution memory. Recalls what worked on past tasks at session start, detects improvement signals while you edit, and records outcomes when a task ends — powered by the Genome Evolution Protocol (GEP). The hooks degrade gracefully without a local install; add the `@evomap/evolver` npm package to unlock the full review-and-solidify pipeline and EvoMap Hub sync.", + "version": "0.1.0", + "author": { + "name": "EvoMap", + "email": "team@evomap.ai" + }, + "publisher": "EvoMap", + "homepage": "https://evomap.ai", + "repository": "https://github.com/EvoMap/evolver-cursor-plugin", + "license": "MIT", + "logo": "assets/logo.png", + "keywords": [ + "evolution", + "self-improvement", + "agent-memory", + "gep", + "meta-learning", + "evomap" + ], + "category": "developer-tools", + "tags": [ + "memory", + "automation", + "self-improvement" + ], + "skills": "./skills/", + "commands": "./commands/", + "rules": "./rules/", + "hooks": "./hooks/hooks.json" +} diff --git a/evolver/.gitignore b/evolver/.gitignore new file mode 100644 index 0000000..9208f89 --- /dev/null +++ b/evolver/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +*.log +.env +.env.* +.DS_Store diff --git a/evolver/LICENSE b/evolver/LICENSE new file mode 100644 index 0000000..6c7514c --- /dev/null +++ b/evolver/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 EvoMap + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/evolver/README.md b/evolver/README.md new file mode 100644 index 0000000..9f1f81d --- /dev/null +++ b/evolver/README.md @@ -0,0 +1,122 @@ +# Evolver — Self-Evolving Agent Memory (Cursor Plugin) + +Give the Cursor agent a **persistent, auditable evolution memory**. Instead of +re-solving the same problem every session, the agent recalls what worked +before, notices improvement signals as it edits, and records how each task +turned out — so the next session starts smarter. + +Powered by the [Genome Evolution Protocol (GEP)](https://evomap.ai) and the +[`@evomap/evolver`](https://github.com/EvoMap/evolver) engine. + +> **Status:** v0.1.0 — hooks + skill + rule. Works standalone (local memory). +> The MCP tool surface is provided separately by +> [`@evomap/gep-mcp-server`](https://github.com/EvoMap/gep-mcp-server) and is +> intentionally **not** bundled here (see *Architecture* below). + +## What it does + +Three hooks run automatically — you don't invoke them: + +| Hook | Event | Effect | +|---|---|---| +| `session-start.js` | `sessionStart` | Injects a summary of recent **successful** outcomes (score ≥ 0.5, < 7 days, max 3) as context. | +| `signal-detect.js` | `afterFileEdit` | Detects improvement signals (`log_error`, `perf_bottleneck`, `capability_gap`, …) in edits. | +| `session-end.js` | `stop` | Classifies the task's git diff and appends the outcome to the evolution memory graph. | + +It also ships: + +- A **`capability-evolver` skill** describing the recall → work → record loop. +- An **`/evolve` command** for a deliberate evolution checkpoint. +- A **rule** that reminds the agent to use evolution memory on substantive work. + +## Install + +### From the Cursor Marketplace + +Search for **Evolver** in the Cursor plugin marketplace and install. + +### Local development + +```bash +git clone https://github.com/EvoMap/evolver-cursor-plugin +ln -s "$(pwd)/evolver-cursor-plugin" ~/.cursor/plugins/local/evolver +``` + +Reload Cursor. The hooks activate on the next session. + +## Requirements + +- **Node.js** (the hooks are Node scripts; Cursor invokes them via `node`). +- Nothing else for local memory. + +## Modes + +### Local mode (default, zero config) + +Out of the box the hooks write outcomes to +`~/.evolver/memory/evolution/memory_graph.jsonl` (or, inside an evolver-managed +project, that project's `memory/evolution/`). Recall and record work +immediately. **No account, no key, no network.** + +### Full engine + +```bash +npm install -g @evomap/evolver +``` + +The bundled hooks always do lightweight **local** recall/record — local git +diff + JSONL append, plus optional Hub sync. Installing `@evomap/evolver` does +**not** change what the hooks do and they do not auto-detect or invoke it. +What it adds is the engine's **CLI** — e.g. `evolver run` (the full automated +review-and-solidify pipeline that analyzes logs and proposes/applies code +improvements) and `evolver review` — which you run separately. The memory the +hooks record feeds that pipeline, so the two compose without the hooks ever +shelling out to the engine. + +### EvoMap Hub (community strategies) + +To sync outcomes and search strategies published by other agents, register an +EvoMap node and set the Hub credentials in your environment: + +```bash +export EVOMAP_HUB_URL="https://evomap.ai" +export EVOMAP_API_KEY="…" # from your EvoMap node +export EVOMAP_NODE_ID="…" +``` + +The `stop` hook will then record outcomes to the Hub (with a local fallback if +the Hub is unreachable). See the [evolver docs](https://evomap.ai) for node +registration. + +## Architecture (why no bundled MCP server) + +EvoMap deliberately splits two products: + +- **`@evomap/evolver`** — the GPL-licensed, source-available evolution engine + (daemon + CLI). This plugin does **not** bundle it; the plugin's own hooks are + an independent MIT clean-room implementation that records memory in the same + format the engine reads, so the two interoperate when you install it. +- **`@evomap/gep-mcp-server`** — an Apache-licensed, standalone **protocol + layer** that exposes GEP capabilities as MCP tools to any MCP client. + +This plugin ships its own lightweight session-lifecycle hooks (the glue Cursor +needs), which work standalone and degrade gracefully. If you also want the +`gep_*` MCP tools inside Cursor, add `@evomap/gep-mcp-server` to your Cursor MCP +config directly — it is not re-bundled here to avoid duplicating that +separately-maintained product. + +## Environment variables + +| Variable | Default | Purpose | +|---|---|---| +| `MEMORY_GRAPH_PATH` | (auto) | Override the memory graph file location. | +| `EVOMAP_HUB_URL` / `EVOMAP_API_KEY` / `EVOMAP_NODE_ID` | (unset) | Enable Hub recording. | +| `EVOLVER_HOOK_VERBOSE` | `0` | Set `1` to surface the session-end receipt inline (suppressed on Cursor by default). | + +## License + +MIT © EvoMap. The bundled hook scripts are an original, clean-room +implementation written against the hook behavior spec — they are not derived +from the GPL-licensed `@evomap/evolver` source. Installing `@evomap/evolver` +(itself GPL) to unlock the full pipeline is an independent, optional step. See +`LICENSE`. diff --git a/evolver/assets/logo.png b/evolver/assets/logo.png new file mode 100644 index 0000000..783ff80 Binary files /dev/null and b/evolver/assets/logo.png differ diff --git a/evolver/commands/evolve.md b/evolver/commands/evolve.md new file mode 100644 index 0000000..cb69e67 --- /dev/null +++ b/evolver/commands/evolve.md @@ -0,0 +1,31 @@ +--- +description: Run an evolution cycle — recall relevant past outcomes, reflect on the current task, and record what was learned. +--- + +# /evolve + +Trigger a deliberate evolution step for the current task. + +1. **Recall.** Look at the evolution memory the session-start hook injected (or + read the tail of the memory graph at + `~/.evolver/memory/evolution/memory_graph.jsonl`, or the project's + `memory/evolution/memory_graph.jsonl` if present). Summarize any recent + outcome — success or failure — that is relevant to what we're working on. + +2. **Reflect.** Given the current diff / task state, state in one or two lines: + what worked, what didn't, and what the durable lesson is. + +3. **Record.** The `stop` hook records outcomes automatically at task end. If + the user wants to record *now*, and the full engine is installed + (`@evomap/evolver` on `PATH`), run: + + ```bash + evolver run + ``` + + to execute a full evolution cycle. If it is not installed, tell the user the + outcome will still be captured automatically by the stop hook, and that + `npm install -g @evomap/evolver` unlocks the full review-and-solidify cycle. + +Keep this lightweight — `/evolve` is for an explicit checkpoint, not a ceremony +on every turn. diff --git a/evolver/hooks/_filter.js b/evolver/hooks/_filter.js new file mode 100644 index 0000000..d36a8fa --- /dev/null +++ b/evolver/hooks/_filter.js @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap +// +// Relevance filter for evolution memory entries. Decides which recorded +// outcomes are worth surfacing back to the agent at session start. + +'use strict'; + +const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; +const MIN_SCORE = 0.5; +const MAX_RESULTS = 3; + +/** + * Parse an entry timestamp into epoch milliseconds, or NaN if absent/invalid. + */ +function timestampMs(entry) { + if (!entry || typeof entry.timestamp !== 'string') { + return NaN; + } + return Date.parse(entry.timestamp); +} + +/** + * Keep only recent, successful, high-scoring outcomes — at most the latest 3. + * + * An entry survives when ALL of the following hold: + * - outcome.status === 'success' + * - outcome.score >= 0.5 + * - its timestamp is within the last 7 days + * + * @param {Array} entries + * @returns {Array} + */ +function filterRelevant(entries) { + if (!Array.isArray(entries)) { + return []; + } + + const now = Date.now(); + const cutoff = now - SEVEN_DAYS_MS; + + const relevant = entries.filter((entry) => { + const outcome = entry && entry.outcome; + if (!outcome || outcome.status !== 'success') { + return false; + } + if (typeof outcome.score !== 'number' || outcome.score < MIN_SCORE) { + return false; + } + const ts = timestampMs(entry); + if (Number.isNaN(ts)) { + return false; + } + return ts >= cutoff && ts <= now; + }); + + // The input arrives chronologically; the most useful items are the latest, + // so keep the tail. + if (relevant.length > MAX_RESULTS) { + return relevant.slice(relevant.length - MAX_RESULTS); + } + return relevant; +} + +module.exports = { filterRelevant }; diff --git a/evolver/hooks/_paths.js b/evolver/hooks/_paths.js new file mode 100644 index 0000000..1d83819 --- /dev/null +++ b/evolver/hooks/_paths.js @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap +// +// Shared path / workspace helpers for the Evolver Cursor plugin hooks. +// Pure Node.js built-ins, no external dependencies. Every exported helper is +// defensive: it must never throw, because the hooks that call it are expected +// to emit valid JSON and exit 0 even under failure conditions. + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const crypto = require('crypto'); +const { spawnSync } = require('child_process'); + +// Pattern an external tool relies on for the workspace identifier: a lowercase +// hex string of at least 32 characters. Keep this in sync with the contract. +const WORKSPACE_ID_PATTERN = /^[a-f0-9]{32,}$/i; + +/** + * Return true when `candidate` is a string pointing at an existing directory. + * Any stat failure is swallowed and treated as "not a directory". + */ +function looksLikeDir(candidate) { + if (typeof candidate !== 'string' || candidate.length === 0) { + return false; + } + try { + return fs.statSync(candidate).isDirectory(); + } catch (_err) { + return false; + } +} + +/** + * Resolve the directory of the user's current project. + * + * Preference order: + * 1. CURSOR_PROJECT_DIR (if it names an existing directory) + * 2. CLAUDE_PROJECT_DIR (if it names an existing directory) + * 3. the process working directory + */ +function resolveProjectDir() { + const fromCursor = process.env.CURSOR_PROJECT_DIR; + if (looksLikeDir(fromCursor)) { + return fromCursor; + } + const fromClaude = process.env.CLAUDE_PROJECT_DIR; + if (looksLikeDir(fromClaude)) { + return fromClaude; + } + return process.cwd(); +} + +/** + * Determine whether `dir` lives inside a git working tree. + * Shells out to `git rev-parse --is-inside-work-tree`. Returns false on any + * problem (git missing, not a repo, timeout, etc.). + */ +function isGitWorkspace(dir) { + try { + const result = spawnSync( + 'git', + ['rev-parse', '--is-inside-work-tree'], + { + cwd: looksLikeDir(dir) ? dir : undefined, + shell: false, + timeout: 5000, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + if (result.status !== 0 || typeof result.stdout !== 'string') { + return false; + } + return result.stdout.trim() === 'true'; + } catch (_err) { + return false; + } +} + +/** + * Return the path to the evolution memory graph (a JSONL file). + * + * Resolution order: + * 1. MEMORY_GRAPH_PATH override, if set. + * 2. `/memory/evolution/memory_graph.jsonl` — but only if it + * already EXISTS (an evolver-managed project owns this file). We never + * create a project-local graph in an arbitrary folder, so plain projects + * fall through to the user-level path. + * 3. The user-level `~/.evolver/memory/evolution/memory_graph.jsonl`, whose + * parent directory is best-effort created. + */ +function findMemoryGraph(projectDir) { + const override = process.env.MEMORY_GRAPH_PATH; + if (typeof override === 'string' && override.length > 0) { + return override; + } + if (looksLikeDir(projectDir)) { + const projectPath = path.join( + projectDir, + 'memory', + 'evolution', + 'memory_graph.jsonl' + ); + try { + if (fs.statSync(projectPath).isFile()) { + return projectPath; + } + } catch (_err) { + // Not present — fall through to the user-level default. + } + } + const defaultPath = path.join( + os.homedir(), + '.evolver', + 'memory', + 'evolution', + 'memory_graph.jsonl' + ); + try { + fs.mkdirSync(path.dirname(defaultPath), { recursive: true }); + } catch (_err) { + // Best effort only; callers tolerate a missing directory. + } + return defaultPath; +} + +/** + * Walk upward from `start` looking for the directory that directly contains a + * `.git` entry (either a directory for normal repos or a file for worktrees / + * submodules). Returns the repo root, or null if none is found. + */ +function findRepoRoot(start) { + let current = path.resolve(start); + // Guard against pathological loops on weird filesystems. + let guard = 0; + while (guard < 256) { + guard += 1; + try { + if (fs.existsSync(path.join(current, '.git'))) { + return current; + } + } catch (_err) { + // Ignore and keep climbing. + } + const parent = path.dirname(current); + if (parent === current) { + break; // reached filesystem root + } + current = parent; + } + return null; +} + +/** + * Read the workspace-id file at `idFile`, applying symlink / regular-file + * guards. Returns the validated id string, or null if the file is missing, + * a symlink, not a regular file, or malformed. + * + * `dotEvolverDir` is the `.evolver` directory; if it is itself a symlink we + * refuse to trust anything beneath it. + */ +function readWorkspaceIdFile(dotEvolverDir, idFile) { + // Refuse to follow a symlinked `.evolver` directory. + let dirStat; + try { + dirStat = fs.lstatSync(dotEvolverDir); + } catch (_err) { + return { ok: false, missing: true }; + } + if (dirStat.isSymbolicLink()) { + return { ok: false, missing: false }; + } + + let fileStat; + try { + fileStat = fs.lstatSync(idFile); + } catch (_err) { + // ENOENT (or similar) => treat as missing so the caller may create it. + return { ok: false, missing: true }; + } + if (fileStat.isSymbolicLink() || !fileStat.isFile()) { + return { ok: false, missing: false }; + } + + let raw; + try { + raw = fs.readFileSync(idFile, 'utf8'); + } catch (_err) { + return { ok: false, missing: false }; + } + const value = raw.trim(); + if (WORKSPACE_ID_PATTERN.test(value)) { + return { ok: true, id: value }; + } + return { ok: false, missing: false }; +} + +/** + * Compute the workspace root used to anchor the workspace-id file. + * - OPENCLAW_WORKSPACE wins if set. + * - Otherwise find the git repo root above `projectDir`; if that root has a + * `workspace/` subdirectory use it, else the root itself. + * - If no repo root exists, fall back to `projectDir`. + */ +function computeWorkspaceRoot(projectDir) { + const explicit = process.env.OPENCLAW_WORKSPACE; + if (typeof explicit === 'string' && explicit.length > 0) { + return explicit; + } + const repoRoot = findRepoRoot(projectDir); + if (!repoRoot) { + return projectDir; + } + const nestedWorkspace = path.join(repoRoot, 'workspace'); + if (looksLikeDir(nestedWorkspace)) { + return nestedWorkspace; + } + return repoRoot; +} + +/** + * Resolve (or lazily create) the forge-resistant workspace identifier. + * + * Contract with external tooling — do not change without coordination: + * - file path: /.evolver/workspace-id + * - file mode: 0600 + * - format: a single 32+ char hex string + * + * Returns the id string, or null when it cannot be safely read or created. + * Never throws. + */ +function resolveWorkspaceId(projectDir) { + try { + const fromEnv = process.env.EVOLVER_WORKSPACE_ID; + if (typeof fromEnv === 'string' && fromEnv.length > 0) { + return String(fromEnv); + } + + const workspaceRoot = computeWorkspaceRoot(projectDir); + const dotEvolverDir = path.join(workspaceRoot, '.evolver'); + const idFile = path.join(dotEvolverDir, 'workspace-id'); + + // First attempt: read an existing, trusted file. + const existing = readWorkspaceIdFile(dotEvolverDir, idFile); + if (existing.ok) { + return existing.id; + } + if (!existing.missing) { + // A file (or `.evolver`) is present but failed the guards. Never clobber + // it — surface "unknown" instead. + return null; + } + + // File is genuinely missing: create it. Re-check the `.evolver` symlink + // guard right before writing. + try { + const dirStat = fs.lstatSync(dotEvolverDir); + if (dirStat.isSymbolicLink()) { + return null; + } + } catch (_err) { + // Does not exist yet — that is fine, mkdir below. + } + + try { + fs.mkdirSync(dotEvolverDir, { recursive: true }); + } catch (_err) { + return null; + } + + const fresh = crypto.randomBytes(16).toString('hex'); // 32 hex chars + let fd; + try { + // O_EXCL + O_NOFOLLOW: fail rather than follow a symlink or overwrite a + // racing writer's file. + const flags = + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + fs.constants.O_NOFOLLOW; + fd = fs.openSync(idFile, flags, 0o600); + fs.writeSync(fd, fresh); + } catch (err) { + if (err && err.code === 'EEXIST') { + // Someone created it between our check and write — re-read it through + // the same guards. + const raced = readWorkspaceIdFile(dotEvolverDir, idFile); + return raced.ok ? raced.id : null; + } + return null; + } finally { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch (_err) { + // ignore + } + } + } + + // Tighten permissions in case the umask widened them. + try { + fs.chmodSync(idFile, 0o600); + } catch (_err) { + // best effort + } + return fresh; + } catch (_err) { + // EACCES / EIO / anything else: degrade to "unknown workspace". + return null; + } +} + +module.exports = { + resolveProjectDir, + isGitWorkspace, + findMemoryGraph, + resolveWorkspaceId, +}; diff --git a/evolver/hooks/_signals.js b/evolver/hooks/_signals.js new file mode 100644 index 0000000..056e86e --- /dev/null +++ b/evolver/hooks/_signals.js @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap +// +// Keyword-based evolution signal detection. Shared by the afterFileEdit and +// stop hooks. Deliberately simple: substring matching against a small, +// hand-curated keyword table, with a heuristic to skip code/comment lines. + +'use strict'; + +// Each signal category maps to a set of lowercase trigger phrases. A category +// fires if any of its phrases appears as a substring of the (lowercased) text. +const SIGNAL_KEYWORDS = { + perf_bottleneck: [ + 'timeout', + 'slow', + 'latency', + 'bottleneck', + 'oom', + 'out of memory', + 'performance', + ], + capability_gap: [ + 'not supported', + 'unsupported', + 'not implemented', + 'missing feature', + 'not available', + ], + log_error: [ + 'error:', + 'exception:', + 'typeerror', + 'referenceerror', + 'syntaxerror', + 'failed', + ], + user_feature_request: [ + 'add feature', + 'implement', + 'new function', + 'new module', + 'please add', + ], + recurring_error: [ + 'same error', + 'still failing', + 'not fixed', + 'keeps failing', + 'repeatedly', + ], + deployment_issue: [ + 'deploy failed', + 'build failed', + 'ci failed', + 'pipeline', + 'rollback', + ], + test_failure: [ + 'test failed', + 'test failure', + 'assertion', + 'expect(', + 'assert.', + ], +}; + +// Prefixes that mark a line as "probably code or a comment" — we skip those to +// cut down on false positives from source files that merely mention keywords. +const CODE_LINE_PREFIXES = ['//', '#', '*', '{', '[', '}', ']', '/*']; + +function looksLikeCode(trimmedLine) { + for (const prefix of CODE_LINE_PREFIXES) { + if (trimmedLine.startsWith(prefix)) { + return true; + } + } + return false; +} + +/** + * Detect evolution signals within free-form text. + * + * @param {string} text + * @returns {string[]} sorted, de-duplicated list of signal category names + */ +function detectSignals(text) { + if (typeof text !== 'string' || text.length === 0) { + return []; + } + + // Build the prose-only corpus: drop lines that look like code/comments. + const prose = text + .split('\n') + .filter((line) => { + const trimmed = line.trim(); + if (!trimmed) { + return false; + } + return !looksLikeCode(trimmed); + }) + .join('\n') + .toLowerCase(); + + if (!prose) { + return []; + } + + const found = new Set(); + for (const [category, phrases] of Object.entries(SIGNAL_KEYWORDS)) { + for (const phrase of phrases) { + if (prose.indexOf(phrase) !== -1) { + found.add(category); + break; + } + } + } + return Array.from(found).sort(); +} + +module.exports = { detectSignals, SIGNAL_KEYWORDS }; diff --git a/evolver/hooks/hooks.json b/evolver/hooks/hooks.json new file mode 100644 index 0000000..99c4da3 --- /dev/null +++ b/evolver/hooks/hooks.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/session-start.js\"", + "timeout": 3 + } + ], + "afterFileEdit": [ + { + "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/signal-detect.js\"", + "timeout": 2 + } + ], + "stop": [ + { + "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/session-end.js\"", + "timeout": 8, + "loop_limit": 1 + } + ] + } +} diff --git a/evolver/hooks/session-end.js b/evolver/hooks/session-end.js new file mode 100644 index 0000000..c198f69 --- /dev/null +++ b/evolver/hooks/session-end.js @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap +// +// Cursor hook: stop. +// Records the outcome of the session by inspecting the git diff of the project +// directory, writing a memory-graph entry (and optionally posting to a Hub), +// and leaving a breadcrumb in the evolution log. +// +// Invocation: `node session-end.js` with a JSON object on stdin. +// Output: a JSON object on stdout, exit 0. On any failure: `{}`. + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const { resolveProjectDir, findMemoryGraph, resolveWorkspaceId } = require('./_paths'); +const { detectSignals } = require('./_signals'); + +const STDIN_WATCHDOG_MS = 7000; +const GIT_TIMEOUT_MS = 5000; +const GIT_MAX_BUFFER = 10 * 1024 * 1024; // 10 MB +const HUB_TIMEOUT_MS = 8000; + +let alreadyEmitted = false; + +/** Emit JSON exactly once and exit. */ +function emit(obj) { + if (alreadyEmitted) { + return; + } + alreadyEmitted = true; + let text = '{}'; + try { + text = JSON.stringify(obj); + } catch (_err) { + text = '{}'; + } + process.stdout.write(text); + process.exit(0); +} + +/** + * Append a timestamped line to the evolution log. Best effort; never throws. + */ +function appendEvolutionLog(line) { + try { + const dir = + process.env.EVOLVER_HOOK_LOG_DIR || + path.join(os.homedir(), '.evolver', 'logs'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'evolution.log'); + fs.appendFileSync(file, `${new Date().toISOString()} ${line}\n`); + } catch (_err) { + // best effort + } +} + +/** Run a git subcommand in `cwd`, returning { status, stdout } (stdout = ''). */ +function git(args, cwd) { + try { + const result = spawnSync('git', args, { + cwd, + shell: false, + timeout: GIT_TIMEOUT_MS, + maxBuffer: GIT_MAX_BUFFER, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { + status: typeof result.status === 'number' ? result.status : 1, + stdout: typeof result.stdout === 'string' ? result.stdout : '', + }; + } catch (_err) { + return { status: 1, stdout: '' }; + } +} + +/** + * Collect the git diff for the session. + * - statText: output of `git diff --stat HEAD~1`, falling back to plain diff. + * - body: output of `git diff --no-color HEAD~1`, likewise with fallback. + * - isRepo: whether we're inside a git work tree. + */ +function collectDiff(projectDir) { + const insideTree = git(['rev-parse', '--is-inside-work-tree'], projectDir); + const isRepo = insideTree.status === 0 && insideTree.stdout.trim() === 'true'; + + let stat = git(['diff', '--stat', 'HEAD~1'], projectDir); + if (stat.status !== 0) { + stat = git(['diff', '--stat'], projectDir); + } + + let body = git(['diff', '--no-color', 'HEAD~1'], projectDir); + if (body.status !== 0) { + body = git(['diff', '--no-color'], projectDir); + } + + return { + isRepo, + statText: stat.stdout || '', + body: body.stdout || '', + }; +} + +/** + * Parse "N files changed, A insertions(+), D deletions(-)" from a --stat tail. + * Missing pieces default to 0. + */ +function parseStat(statText) { + const files = (statText.match(/(\d+)\s+files?\s+changed/) || [])[1]; + const ins = (statText.match(/(\d+)\s+insertions?\(\+\)/) || [])[1]; + const del = (statText.match(/(\d+)\s+deletions?\(-\)/) || [])[1]; + return { + files: files ? parseInt(files, 10) : 0, + insertions: ins ? parseInt(ins, 10) : 0, + deletions: del ? parseInt(del, 10) : 0, + }; +} + +/** True when running inside the Cursor host (unless explicitly forced verbose). */ +function isCursorHost() { + const verbose = process.env.EVOLVER_HOOK_VERBOSE; + if (verbose === '1' || verbose === 'true') { + return false; // escape hatch: always speak up + } + return ( + process.env.EVOLVER_HOOK_HOST === 'cursor' || + process.env.TERM_PROGRAM === 'cursor' || + typeof process.env.CURSOR_TRACE_ID === 'string' || + typeof process.env.CURSOR_SESSION_ID === 'string' + ); +} + +/** + * Attempt to POST the outcome to a configured Hub via curl. Returns true on a + * zero-exit curl. Never throws. + */ +function recordToHub(payload) { + try { + const hubUrl = process.env.EVOMAP_HUB_URL || process.env.A2A_HUB_URL; + const apiKey = process.env.EVOMAP_API_KEY || process.env.A2A_NODE_SECRET; + if (!hubUrl || !apiKey) { + return false; + } + const url = `${hubUrl.replace(/\/+$/, '')}/a2a/evolution/record`; + const result = spawnSync( + 'curl', + [ + '-s', + '-S', + '-X', + 'POST', + '-H', + 'Content-Type: application/json', + '-H', + `Authorization: Bearer ${apiKey}`, + '--max-time', + '8', + '--data-binary', + JSON.stringify(payload), + url, + ], + { + shell: false, + timeout: HUB_TIMEOUT_MS, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + } + ); + return result.status === 0; + } catch (_err) { + return false; + } +} + +/** + * Append one JSON entry to the memory graph. The field shape here is a hard + * contract consumed by external tooling — keep it exact. Returns true on + * success. + */ +function recordToLocal(entry, projectDir) { + try { + const graphPath = findMemoryGraph(projectDir); + fs.mkdirSync(path.dirname(graphPath), { recursive: true }); + fs.appendFileSync(graphPath, `${JSON.stringify(entry)}\n`); + return true; + } catch (_err) { + return false; + } +} + +function finish(projectDir, diff) { + const stats = parseStat(diff.statText); + const hasChanges = diff.statText.trim().length > 0; + + // No changes: just leave a breadcrumb, never a memory-graph entry. + if (!hasChanges) { + const reason = diff.isRepo + ? 'no changes detected this session' + : 'not a git workspace'; + appendEvolutionLog(`[Evolution] Session end: nothing recorded (${reason}).`); + emit({}); + return; + } + + // Changes present: derive signals / status / score. + let signals = detectSignals(diff.body); + if (signals.length === 0) { + signals = ['stable_success_plateau']; + } + const failed = signals.includes('log_error') || signals.includes('test_failure'); + const status = failed ? 'failed' : 'success'; + const score = failed ? 0.3 : 0.8; + + const summary = + `Session end: ${stats.files} files changed, ` + + `+${stats.insertions}/-${stats.deletions}. Signals: [${signals.join(', ')}]`; + + // Try the Hub first (if configured). + const hubOk = recordToHub({ + gene_id: 'ad_hoc', + signals, + status, + score, + summary, + sender_id: process.env.EVOMAP_NODE_ID || process.env.A2A_NODE_ID, + }); + + // Always also attempt a local record. + const localOk = recordToLocal( + { + timestamp: new Date().toISOString(), + gene_id: 'ad_hoc', + signals, + outcome: { status, score, note: summary }, + cwd: projectDir, + workspace_id: resolveWorkspaceId(projectDir), + source: 'hook:session-end', + }, + projectDir + ); + + let destination; + if (hubOk) { + destination = 'Hub'; + } else if (localOk) { + destination = 'local memory'; + } else { + destination = 'nowhere (no Hub or local path)'; + } + const receipt = `[Evolution] Session outcome recorded to ${destination}: ${summary}`; + appendEvolutionLog(receipt); + + // Cursor mishandles a stop-hook systemMessage (it re-injects it as a prompt), + // so suppress output entirely when we detect the Cursor host. + if (isCursorHost()) { + emit({}); + return; + } + emit({ systemMessage: receipt }); +} + +// Drain stdin (we don't use it) with a watchdog, then do the work. +(function run() { + try { + const projectDir = resolveProjectDir(); + let done = false; + + const proceed = () => { + if (done) { + return; + } + done = true; + try { + const diff = collectDiff(projectDir); + finish(projectDir, diff); + } catch (_err) { + emit({}); + } + }; + + const watchdog = setTimeout(() => { + // Stdin never closed in time — still do the work (proceed() is guarded + // by `done`, so it runs at most once whether the timeout or `end` fires). + proceed(); + }, STDIN_WATCHDOG_MS); + if (typeof watchdog.unref === 'function') { + watchdog.unref(); + } + + process.stdin.on('data', () => {}); + process.stdin.on('end', () => { + clearTimeout(watchdog); + proceed(); + }); + process.stdin.on('error', () => { + clearTimeout(watchdog); + proceed(); + }); + process.stdin.resume(); + } catch (_err) { + emit({}); + } +})(); diff --git a/evolver/hooks/session-start.js b/evolver/hooks/session-start.js new file mode 100644 index 0000000..2cebcc1 --- /dev/null +++ b/evolver/hooks/session-start.js @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap +// +// Cursor hook: sessionStart. +// Surfaces recent, workspace-scoped evolution memory (and a one-time notice if +// the folder isn't a git repo) to the agent as additional context. +// +// Invocation: `node session-start.js` with a JSON object on stdin. +// Output: a JSON object on stdout, exit 0. On any failure: `{}`. + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + resolveProjectDir, + isGitWorkspace, + findMemoryGraph, + resolveWorkspaceId, +} = require('./_paths'); +const { filterRelevant } = require('./_filter'); + +const MAX_SCAN_ENTRIES = 5; // how many workspace-matched entries to gather +const LINE_MAX = 200; // per-outcome line truncation +const NONGIT_TTL_MS = 30 * 60 * 1000; // throttle the non-git notice +const THROTTLE_PRUNE_MS = 24 * 60 * 60 * 1000; + +const NONGIT_NOTICE = + '[Evolver] This folder is not a git repository, so evolution memory is ' + + 'inactive (outcomes are derived from git diffs). Run `git init` here, or ' + + 'open a git project, to enable recall and recording.'; + +// The hook's own timeout is 3s; give stdin a slightly shorter window to drain. +const STDIN_WATCHDOG_MS = 2000; + +let alreadyEmitted = false; + +/** + * Emit a JSON object exactly once and exit cleanly. Falls back to `{}` if + * serialization somehow fails. + */ +function emit(obj) { + if (alreadyEmitted) { + return; + } + alreadyEmitted = true; + let text = '{}'; + try { + text = JSON.stringify(obj); + } catch (_err) { + text = '{}'; + } + process.stdout.write(text); + process.exit(0); +} + +/** + * Lightweight throttle backed by a small JSON map of key -> last-fired epoch. + * Returns true when `key` fired within `ttlMs` (caller should suppress). + * Otherwise records "now" and returns false. Fails open (false) on any error. + */ +function throttled(key, ttlMs) { + try { + const base = + process.env.EVOLVER_SESSION_STATE_DIR || + path.join(os.homedir(), '.evolver'); + const stateFile = path.join(base, 'session-start-state.json'); + + let state = {}; + try { + state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + if (!state || typeof state !== 'object') { + state = {}; + } + } catch (_err) { + state = {}; + } + + const now = Date.now(); + const last = state[key]; + if (typeof last === 'number' && now - last < ttlMs) { + return true; // recently fired -> suppress + } + + // Record this firing and prune stale entries. + state[key] = now; + for (const k of Object.keys(state)) { + if (typeof state[k] !== 'number' || now - state[k] > THROTTLE_PRUNE_MS) { + delete state[k]; + } + } + + try { + fs.mkdirSync(base, { recursive: true }); + fs.writeFileSync(stateFile, JSON.stringify(state)); + } catch (_err) { + // best effort + } + return false; + } catch (_err) { + return false; // fail open + } +} + +/** + * Decide whether a memory entry belongs to the current workspace. + * - tagged with workspace_id, our id known: match iff equal. + * - tagged with workspace_id, our id UNKNOWN: do not blanket-include (that + * would leak other workspaces' entries from a shared graph). Fall back to + * cwd matching: in-scope iff the entry's cwd equals currentDir; if the + * entry has no cwd and currentDir is unknown too, then (and only then) + * don't exclude. + * - else tagged with cwd: match iff equal (lenient only when currentDir + * is unknown). + * - untagged (no workspace_id and no cwd): always include (legacy). + */ +function belongsToWorkspace(entry, currentId, currentDir) { + if (entry && typeof entry.workspace_id === 'string' && entry.workspace_id) { + if (currentId === null || currentId === undefined) { + // Cannot resolve our own id — fall back to cwd matching rather than + // surfacing a possibly-foreign workspace's entries. + if (typeof entry.cwd === 'string' && entry.cwd) { + return currentDir ? entry.cwd === currentDir : false; + } + return !currentDir; + } + return entry.workspace_id === currentId; + } + if (entry && typeof entry.cwd === 'string' && entry.cwd) { + if (!currentDir) { + return true; + } + return entry.cwd === currentDir; + } + return true; +} + +/** + * Read the JSONL graph and gather up to MAX_SCAN_ENTRIES entries belonging to + * this workspace, scanning from newest (end) to oldest. Returns them in + * chronological order. + */ +function gatherWorkspaceEntries(graphPath, currentId, currentDir) { + let content; + try { + content = fs.readFileSync(graphPath, 'utf8'); + } catch (_err) { + return []; + } + + const lines = content.split('\n'); + const collected = []; + for (let i = lines.length - 1; i >= 0; i -= 1) { + const line = lines[i].trim(); + if (!line) { + continue; + } + let entry; + try { + entry = JSON.parse(line); + } catch (_err) { + continue; // skip malformed lines + } + if (belongsToWorkspace(entry, currentId, currentDir)) { + collected.push(entry); + if (collected.length >= MAX_SCAN_ENTRIES) { + break; + } + } + } + + collected.reverse(); // newest-first -> chronological + return collected; +} + +/** + * Format the human-readable outcome summary block from filtered entries. + */ +function formatSummary(outcomes) { + const successes = outcomes.filter( + (o) => o.outcome && o.outcome.status === 'success' + ).length; + const failures = outcomes.filter( + (o) => o.outcome && o.outcome.status === 'failed' + ).length; + + const header = + `[Evolution Memory] Recent ${outcomes.length} outcomes ` + + `(${successes} success, ${failures} failed):`; + + const rows = outcomes.map((entry) => { + const outcome = entry.outcome || {}; + let icon = '?'; + if (outcome.status === 'success') { + icon = '+'; + } else if (outcome.status === 'failed') { + icon = '-'; + } + const date = + typeof entry.timestamp === 'string' + ? entry.timestamp.slice(0, 10) + : '??????????'; + const score = + typeof outcome.score === 'number' ? outcome.score : '?'; + const signals = Array.isArray(entry.signals) + ? entry.signals.slice(0, 3).join(', ') + : ''; + const note = + typeof outcome.note === 'string' ? outcome.note : ''; + const line = `[${icon}] ${date} score=${score} signals=[${signals}] ${note}`; + return line.length > LINE_MAX ? line.slice(0, LINE_MAX) : line; + }); + + return ( + [header, ...rows].join('\n') + + '\n\nUse successful approaches. Avoid repeating failed patterns.' + ); +} + +function main() { + const parts = []; + const currentDir = resolveProjectDir(); + + // 1. Non-git notice (throttled per directory). + try { + if (!isGitWorkspace(currentDir)) { + if (!throttled(`nongit:${currentDir}`, NONGIT_TTL_MS)) { + parts.push(NONGIT_NOTICE); + } + } + } catch (_err) { + // ignore — notice is optional + } + + // 2. Workspace-scoped evolution memory. + try { + const graphPath = findMemoryGraph(currentDir); + const currentId = resolveWorkspaceId(currentDir); + const candidates = gatherWorkspaceEntries(graphPath, currentId, currentDir); + const relevant = filterRelevant(candidates); + if (relevant.length > 0) { + parts.push(formatSummary(relevant)); + } + } catch (_err) { + // ignore — memory injection is optional + } + + if (parts.length === 0) { + emit({}); + return; + } + + const joined = parts.join('\n\n'); + emit({ agent_message: joined, additionalContext: joined }); +} + +// This hook does not need stdin, but Cursor still pipes a JSON object. Drain it +// (so the writer never races a half-drained pipe / gets EPIPE) and only then +// run main(). A short watchdog guarantees we still run if stdin never closes. +// main() stays synchronous; we only gate when it is invoked. Guard everything. +(function run() { + try { + let started = false; + const start = () => { + if (started) { + return; + } + started = true; + try { + main(); + } catch (_err) { + emit({}); + } + }; + + const watchdog = setTimeout(start, STDIN_WATCHDOG_MS); + if (typeof watchdog.unref === 'function') { + watchdog.unref(); + } + + // Consume stdin without blocking; we don't actually use its contents. + process.stdin.on('data', () => {}); + process.stdin.on('end', () => { + clearTimeout(watchdog); + start(); + }); + process.stdin.on('error', () => { + clearTimeout(watchdog); + start(); + }); + process.stdin.resume(); + } catch (_err) { + emit({}); + } +})(); diff --git a/evolver/hooks/signal-detect.js b/evolver/hooks/signal-detect.js new file mode 100644 index 0000000..bf96540 --- /dev/null +++ b/evolver/hooks/signal-detect.js @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EvoMap +// +// Cursor hook: afterFileEdit. +// Inspects freshly edited content for evolution signals (errors, perf hints, +// feature requests, ...) and, when any are found, nudges the agent to consider +// recording the outcome. +// +// Invocation: `node signal-detect.js` with a JSON object on stdin. +// Output: a JSON object on stdout, exit 0. On any failure / timeout: `{}`. + +'use strict'; + +const { detectSignals } = require('./_signals'); + +const STDIN_WATCHDOG_MS = 1500; + +let alreadyEmitted = false; + +/** Emit JSON exactly once and exit. */ +function emit(obj) { + if (alreadyEmitted) { + return; + } + alreadyEmitted = true; + let text = '{}'; + try { + text = JSON.stringify(obj); + } catch (_err) { + text = '{}'; + } + process.stdout.write(text); + process.exit(0); +} + +/** + * Pull the edited content out of the various shapes Cursor / Claude may use. + */ +function extractContent(input) { + if (!input || typeof input !== 'object') { + return ''; + } + const ti = input.tool_input; + if (ti && typeof ti === 'object') { + if (typeof ti.content === 'string') return ti.content; + if (typeof ti.new_string === 'string') return ti.new_string; + if (typeof ti.file_content === 'string') return ti.file_content; + } + if (typeof input.content === 'string') return input.content; + if (typeof input.file_content === 'string') return input.file_content; + if (typeof input.diff === 'string') return input.diff; + return ''; +} + +/** + * Pull the edited file path out of the various shapes. + */ +function extractFilePath(input) { + if (!input || typeof input !== 'object') { + return ''; + } + const ti = input.tool_input; + if (ti && typeof ti === 'object' && typeof ti.file_path === 'string') { + return ti.file_path; + } + const tr = input.tool_response; + if (tr && typeof tr === 'object' && typeof tr.filePath === 'string') { + return tr.filePath; + } + if (typeof input.path === 'string') return input.path; + if (typeof input.file_path === 'string') return input.file_path; + return ''; +} + +function process_(raw) { + let input = {}; + try { + input = raw ? JSON.parse(raw) : {}; + } catch (_err) { + input = {}; + } + + const content = extractContent(input); + const signals = detectSignals(content); + + if (signals.length === 0) { + emit({}); + return; + } + + const where = extractFilePath(input) || 'edited file'; + const ctx = + `[Evolution Signal] Detected: [${signals.join(', ')}] in ${where}. ` + + 'Consider recording this outcome.'; + + emit({ additional_context: ctx, additionalContext: ctx }); +} + +// Drain stdin with a watchdog so we always exit promptly with valid JSON. +(function run() { + try { + let buffer = ''; + const watchdog = setTimeout(() => { + try { + process_(buffer); + } catch (_err) { + emit({}); + } + }, STDIN_WATCHDOG_MS); + if (typeof watchdog.unref === 'function') { + watchdog.unref(); + } + + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { + buffer += chunk; + }); + process.stdin.on('end', () => { + clearTimeout(watchdog); + try { + process_(buffer); + } catch (_err) { + emit({}); + } + }); + process.stdin.on('error', () => { + clearTimeout(watchdog); + emit({}); + }); + process.stdin.resume(); + } catch (_err) { + emit({}); + } +})(); diff --git a/evolver/rules/evolution-memory.mdc b/evolver/rules/evolution-memory.mdc new file mode 100644 index 0000000..d368b58 --- /dev/null +++ b/evolver/rules/evolution-memory.mdc @@ -0,0 +1,25 @@ +--- +description: Use evolution memory on substantive tasks — recall relevant past outcomes before starting, and let outcomes be recorded at the end. +alwaysApply: false +--- + +# Evolution memory + +This workspace has the Evolver plugin installed. It maintains a persistent, +auditable memory of past task outcomes. + +For any **substantive** task (a feature, a non-trivial fix, a refactor): + +- **Before starting**, consult the evolution memory injected at session start. + If a recent *successful* outcome matches the task, reuse that approach. If a + recent *failure* matches, do not repeat it. +- **After finishing**, the outcome is recorded automatically by the stop hook. + If the task carried a clear, durable lesson, state it plainly in your final + message so it is captured. + +Skip this for trivial or purely conversational turns — evolution memory is for +work worth learning from, not ceremony on every reply. + +The `capability-evolver` skill and the `/evolve` command provide the full +workflow. Installing `@evomap/evolver` (`npm install -g @evomap/evolver`) +upgrades the local-only memory into the full review-and-solidify pipeline. diff --git a/evolver/skills/capability-evolver/SKILL.md b/evolver/skills/capability-evolver/SKILL.md new file mode 100644 index 0000000..eb2e2f4 --- /dev/null +++ b/evolver/skills/capability-evolver/SKILL.md @@ -0,0 +1,73 @@ +--- +name: capability-evolver +description: Self-evolution workflow for the agent. Before a substantive task, recall what worked on similar past tasks from evolution memory; after it, record the outcome so future sessions learn from it. Use when the user starts non-trivial work (a feature, a fix, a refactor) or asks the agent to "evolve", "learn from this", or "remember how this went". +--- + +# Capability Evolver + +This plugin gives the agent a **persistent, auditable evolution memory** built on the +Genome Evolution Protocol (GEP). The goal is simple: stop re-solving the same +problem from scratch. Past outcomes — what worked, what failed — are carried +forward into future sessions. + +## How it works (automatic) + +Three hooks run on their own; you don't invoke them: + +- **`sessionStart`** — injects a short summary of recent **successful** outcomes + (filtered to score ≥ 0.5, < 7 days old, max 3) as context. The agent sees + "here's what worked recently" before it starts. +- **`afterFileEdit`** — scans edits for improvement signals (`log_error`, + `perf_bottleneck`, `capability_gap`, `test_failure`, …) and nudges the agent + to record the outcome when relevant. +- **`stop`** — at the end of a task, collects the git diff, classifies the + outcome, and appends it to the evolution memory graph. + +Memory is written to a local JSONL graph. With no extra setup it lands in +`~/.evolver/memory/evolution/memory_graph.jsonl`; inside an evolver-managed +project it lands under that project's `memory/evolution/`. + +## What you (the agent) should do + +For any **substantive** task — a feature, a non-trivial fix, a refactor: + +1. **Before starting**, check the injected evolution memory (it arrives as + session-start context). If a recent successful outcome matches the task, + reuse that approach. If a recent *failure* matches, avoid repeating it. +2. **Do the work.** +3. **After finishing**, the `stop` hook records the outcome automatically. You + don't need to call anything — but if the task had a clear lesson worth a + one-line note, say so in your final message so it's captured in the diff + context the hook reads. + +Trivial or purely conversational turns don't need this — skip it. + +## Signals + +The hooks classify work by signal. Knowing the vocabulary helps you describe +outcomes in terms the memory graph indexes well: + +| Signal | Fires on | +|---|---| +| `log_error` | errors, exceptions, failures in the diff | +| `perf_bottleneck` | timeout / slow / latency / OOM | +| `capability_gap` | "not supported" / "not implemented" | +| `user_feature_request` | adding a feature / new module | +| `test_failure` | failing tests / assertions | +| `deployment_issue` | build / CI / pipeline / rollback | + +## Full pipeline (optional) + +The bundled hooks record outcomes and recall them — that works on its own. To +get the **full evolution engine** (automated log analysis, the +review-and-solidify cycle that proposes and applies code improvements), install +it: + +```bash +npm install -g @evomap/evolver +``` + +This gives you the engine's CLI (e.g. `evolver run`) to run that pipeline +separately — the hooks do not auto-detect or invoke it. The memory the hooks +record is what the pipeline consumes. See the plugin README for connecting an +EvoMap Hub node for community strategies.