From db810724beb0cf5d4ef58a9e0ec7050679796025 Mon Sep 17 00:00:00 2001
From: forrestlinfeng
Date: Mon, 26 Oct 2026 13:46:53 +0800
Subject: [PATCH 1/7] feat(hooks): run Evolver from Cursor hooks, with nothing
in the model context but the recalled steps (2.2.0)
Aligns the Cursor plugin with dsh-evolver 0.3.0 (#51/#52, check readers from #53):
- beforeSubmitPrompt recalls one strategy by the prompt text and adds it as
additional_context, ending with "Apply them where they fit."; inline edits
(no composer_mode) are left alone.
- Tool, edit and response hooks record the turn per generation; stop reports
every injected strategy with the turn's evidence and its own end status;
aborted, correction and retention follow-ups go through the Proxy outbox.
- A verified turn becomes the chat's candidate, dropped by a correction, and
is distilled at session end in a detached worker through cursor-agent,
headless (-p --output-format json --mode ask, empty workspace), with an env
marker that turns this plugin's own hooks off inside that run.
- /evolver-* commands are answered by the beforeSubmitPrompt hook itself
(continue:false), before any model sees them; /evolver-evolve stays the dsh
prompt.
- Removes the MCP server, skill, rule, Configure variables, the session-start
memory summary, the [Evolution Signal] notice and the git-diff outcome
posted to /a2a/evolution/record.
Co-Authored-By: Claude Opus 5.5
---
.cursor-plugin/marketplace.json | 9 +-
.cursor-plugin/plugin.json | 48 +--
.github/workflows/test.yml | 2 +-
CHANGELOG.md | 36 +-
README.md | 282 +++++++--------
commands/distill.md | 15 -
commands/evolve.md | 32 --
commands/evolver-distill.md | 7 +
commands/evolver-evolve.md | 17 +
commands/evolver-review.md | 7 +
commands/evolver-run.md | 7 +
commands/evolver-search.md | 7 +
commands/evolver-solidify.md | 7 +
commands/evolver-status.md | 7 +
commands/evolver-sync.md | 7 +
commands/review.md | 20 --
commands/run.md | 19 -
commands/search.md | 25 --
commands/solidify.md | 19 -
commands/status.md | 49 ---
commands/sync.md | 15 -
hooks/_filter.js | 65 ----
hooks/_paths.js | 529 ----------------------------
hooks/_signals.js | 120 -------
hooks/after-agent-response.mjs | 5 +
hooks/after-file-edit.mjs | 5 +
hooks/hooks.json | 37 +-
hooks/post-tool-use-failure.mjs | 5 +
hooks/post-tool-use.mjs | 5 +
hooks/pre-tool-use.mjs | 6 +
hooks/session-end.js | 448 -----------------------
hooks/session-end.mjs | 28 ++
hooks/session-start.js | 348 ------------------
hooks/session-start.mjs | 44 +++
hooks/signal-detect.js | 134 -------
hooks/stop.mjs | 23 ++
hooks/user-prompt-submit.mjs | 102 ++++++
lib/applied.mjs | 67 ++++
lib/background.mjs | 17 +
lib/checks.mjs | 27 ++
lib/commands.mjs | 182 ++++++++++
lib/cursor-agent.mjs | 107 ++++++
lib/dissatisfaction.mjs | 31 ++
lib/distiller.mjs | 43 +++
lib/engine-job.mjs | 15 +
lib/engine-notice.mjs | 28 ++
lib/engine-runtime.mjs | 117 ++++++
lib/engine-setup.mjs | 101 ++++++
lib/engine-state.mjs | 20 ++
lib/engine-version.mjs | 36 ++
lib/feedback-label.mjs | 12 +
lib/hook-io.mjs | 45 +++
lib/injected-assets.mjs | 93 +++++
lib/local-ledger.mjs | 73 ++++
lib/plugin-commands.mjs | 29 ++
lib/prime.mjs | 175 +++++++++
lib/process-owner.mjs | 65 ++++
lib/project-dir.mjs | 17 +
lib/proxy.mjs | 131 +++++++
lib/recall-log.mjs | 14 +
lib/recording-hook.mjs | 12 +
lib/redact.mjs | 21 ++
lib/report-outbox.mjs | 36 ++
lib/report-queue.mjs | 37 ++
lib/retention-pending.mjs | 39 ++
lib/retention.mjs | 15 +
lib/reuse.mjs | 135 +++++++
lib/session-distill.mjs | 237 +++++++++++++
lib/session-state.mjs | 94 +++++
lib/signals.mjs | 47 +++
lib/state-file.mjs | 190 ++++++++++
lib/status-report.mjs | 98 ++++++
lib/turn-end.mjs | 63 ++++
lib/turn-outcomes.mjs | 11 +
lib/turn-record.mjs | 132 +++++++
lib/verification.mjs | 34 ++
mcp.json | 14 -
mcp/evolver-proxy.mjs | 384 --------------------
package.json | 11 +
rules/evolution-memory.mdc | 25 --
scripts/background-worker.mjs | 33 ++
skills/evolver/SKILL.md | 93 -----
test/applied.test.mjs | 86 +++++
test/checks.test.mjs | 37 ++
test/commands.test.mjs | 98 ++++++
test/cursor-agent.test.mjs | 32 ++
test/engine-setup.test.mjs | 167 +++++++++
test/engine-version.test.mjs | 23 ++
test/feedback-label.test.mjs | 30 ++
test/fixtures/fake-cursor-agent.mjs | 15 +
test/fixtures/lru-turn.json | 16 +
test/helpers/sandbox.mjs | 120 +++++++
test/hooks.test.mjs | 516 +++++++++++++++++++++++++++
test/injected-assets.test.mjs | 42 +++
test/manifest.test.js | 110 ------
test/manifest.test.mjs | 43 +++
test/mcp-bridge.test.js | 133 -------
test/paths.test.js | 98 ------
test/prime.test.mjs | 30 ++
test/process-owner.test.mjs | 29 ++
test/proxy.test.mjs | 76 ++++
test/redact.test.mjs | 30 ++
test/report-outbox.test.mjs | 44 +++
test/retention.test.mjs | 25 ++
test/session-distill.test.mjs | 181 ++++++++++
test/state-file.test.mjs | 69 ++++
test/turn-record.test.mjs | 41 +++
test/verification.test.mjs | 22 ++
test/windows-quoting.test.mjs | 19 +
109 files changed, 4953 insertions(+), 2926 deletions(-)
delete mode 100644 commands/distill.md
delete mode 100644 commands/evolve.md
create mode 100644 commands/evolver-distill.md
create mode 100644 commands/evolver-evolve.md
create mode 100644 commands/evolver-review.md
create mode 100644 commands/evolver-run.md
create mode 100644 commands/evolver-search.md
create mode 100644 commands/evolver-solidify.md
create mode 100644 commands/evolver-status.md
create mode 100644 commands/evolver-sync.md
delete mode 100644 commands/review.md
delete mode 100644 commands/run.md
delete mode 100644 commands/search.md
delete mode 100644 commands/solidify.md
delete mode 100644 commands/status.md
delete mode 100644 commands/sync.md
delete mode 100644 hooks/_filter.js
delete mode 100644 hooks/_paths.js
delete mode 100644 hooks/_signals.js
create mode 100644 hooks/after-agent-response.mjs
create mode 100644 hooks/after-file-edit.mjs
create mode 100644 hooks/post-tool-use-failure.mjs
create mode 100644 hooks/post-tool-use.mjs
create mode 100644 hooks/pre-tool-use.mjs
delete mode 100644 hooks/session-end.js
create mode 100644 hooks/session-end.mjs
delete mode 100644 hooks/session-start.js
create mode 100644 hooks/session-start.mjs
delete mode 100644 hooks/signal-detect.js
create mode 100644 hooks/stop.mjs
create mode 100644 hooks/user-prompt-submit.mjs
create mode 100644 lib/applied.mjs
create mode 100644 lib/background.mjs
create mode 100644 lib/checks.mjs
create mode 100644 lib/commands.mjs
create mode 100644 lib/cursor-agent.mjs
create mode 100644 lib/dissatisfaction.mjs
create mode 100644 lib/distiller.mjs
create mode 100644 lib/engine-job.mjs
create mode 100644 lib/engine-notice.mjs
create mode 100644 lib/engine-runtime.mjs
create mode 100644 lib/engine-setup.mjs
create mode 100644 lib/engine-state.mjs
create mode 100644 lib/engine-version.mjs
create mode 100644 lib/feedback-label.mjs
create mode 100644 lib/hook-io.mjs
create mode 100644 lib/injected-assets.mjs
create mode 100644 lib/local-ledger.mjs
create mode 100644 lib/plugin-commands.mjs
create mode 100644 lib/prime.mjs
create mode 100644 lib/process-owner.mjs
create mode 100644 lib/project-dir.mjs
create mode 100644 lib/proxy.mjs
create mode 100644 lib/recall-log.mjs
create mode 100644 lib/recording-hook.mjs
create mode 100644 lib/redact.mjs
create mode 100644 lib/report-outbox.mjs
create mode 100644 lib/report-queue.mjs
create mode 100644 lib/retention-pending.mjs
create mode 100644 lib/retention.mjs
create mode 100644 lib/reuse.mjs
create mode 100644 lib/session-distill.mjs
create mode 100644 lib/session-state.mjs
create mode 100644 lib/signals.mjs
create mode 100644 lib/state-file.mjs
create mode 100644 lib/status-report.mjs
create mode 100644 lib/turn-end.mjs
create mode 100644 lib/turn-outcomes.mjs
create mode 100644 lib/turn-record.mjs
create mode 100644 lib/verification.mjs
delete mode 100644 mcp.json
delete mode 100644 mcp/evolver-proxy.mjs
create mode 100644 package.json
delete mode 100644 rules/evolution-memory.mdc
create mode 100644 scripts/background-worker.mjs
delete mode 100644 skills/evolver/SKILL.md
create mode 100644 test/applied.test.mjs
create mode 100644 test/checks.test.mjs
create mode 100644 test/commands.test.mjs
create mode 100644 test/cursor-agent.test.mjs
create mode 100644 test/engine-setup.test.mjs
create mode 100644 test/engine-version.test.mjs
create mode 100644 test/feedback-label.test.mjs
create mode 100644 test/fixtures/fake-cursor-agent.mjs
create mode 100644 test/fixtures/lru-turn.json
create mode 100644 test/helpers/sandbox.mjs
create mode 100644 test/hooks.test.mjs
create mode 100644 test/injected-assets.test.mjs
delete mode 100644 test/manifest.test.js
create mode 100644 test/manifest.test.mjs
delete mode 100644 test/mcp-bridge.test.js
delete mode 100644 test/paths.test.js
create mode 100644 test/prime.test.mjs
create mode 100644 test/process-owner.test.mjs
create mode 100644 test/proxy.test.mjs
create mode 100644 test/redact.test.mjs
create mode 100644 test/report-outbox.test.mjs
create mode 100644 test/retention.test.mjs
create mode 100644 test/session-distill.test.mjs
create mode 100644 test/state-file.test.mjs
create mode 100644 test/turn-record.test.mjs
create mode 100644 test/verification.test.mjs
create mode 100644 test/windows-quoting.test.mjs
diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json
index 868c0bf..a21afac 100644
--- a/.cursor-plugin/marketplace.json
+++ b/.cursor-plugin/marketplace.json
@@ -6,14 +6,14 @@
},
"metadata": {
"description": "Official Evolver plugin for Cursor — persistent, auditable evolution memory for the agent, powered by the Genome Evolution Protocol (GEP).",
- "version": "2.1.0"
+ "version": "2.2.0"
},
"plugins": [
{
"name": "evolver",
"source": ".",
- "description": "Persistent, auditable evolution memory for the agent — recalls what worked, detects improvement signals, records outcomes. Powered by the Genome Evolution Protocol (GEP).",
- "version": "2.1.0",
+ "description": "Prompt-relevant strategy recall, turn-end reuse reports and session-end distillation, from hooks only — nothing in the model's context but the recalled steps.",
+ "version": "2.2.0",
"author": {
"name": "EvoMap"
},
@@ -24,8 +24,7 @@
"evolution",
"self-improvement",
"agent-memory",
- "gep",
- "mcp"
+ "gep"
]
}
]
diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index 598bba4..5fcb71a 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -1,8 +1,8 @@
{
"name": "evolver",
"displayName": "Evolver — Self-Evolving Agent Memory",
- "description": "Gives the agent a persistent, auditable evolution memory plus a bridge to the EvoMap network. Recalls what worked at session start, detects improvement signals while you edit, records outcomes when a task ends, and exposes the network's genes/capsules as MCP tools — powered by the Genome Evolution Protocol (GEP). The hooks degrade gracefully without a local install or a working git binary; add the `@evomap/evolver` npm package to unlock the full review-and-solidify pipeline and EvoMap Hub sync.",
- "version": "2.1.0",
+ "description": "Recalls one proven strategy from the local Evolver Proxy behind each prompt, reports at every turn end how it was reused (with the checks the turn ran as evidence), and distills the session's latest verified turn into a Gene when the chat closes — all from hooks, with no tools, skill or rules in the model's context. Powered by the Genome Evolution Protocol (GEP); the plugin sets up the `@evomap/evolver` engine itself.",
+ "version": "2.2.0",
"author": {
"name": "EvoMap",
"email": "team@evomap.ai"
@@ -18,7 +18,6 @@
"agent-memory",
"gep",
"meta-learning",
- "mcp",
"evomap"
],
"category": "developer-tools",
@@ -27,47 +26,6 @@
"automation",
"self-improvement"
],
- "skills": "./skills/",
"commands": "./commands/",
- "rules": "./rules/",
- "hooks": "./hooks/hooks.json",
- "mcpServers": "./mcp.json",
- "variables": {
- "type": "object",
- "properties": {
- "EVOMAP_NODE_ID": {
- "type": "string",
- "title": "EvoMap Node ID — leave blank for automatic setup",
- "description": "Leave this blank (recommended). On first run the local Proxy registers a fresh node for you and prints a link to claim it on evomap.ai — you never paste an id or a secret here. Only fill this in to point the install at a node you already run yourself.",
- "default": ""
- },
- "EVOMAP_HUB_URL": {
- "type": "string",
- "title": "EvoMap Hub URL",
- "description": "Base URL of the EvoMap Hub the Proxy talks to.",
- "default": "https://evomap.ai"
- },
- "EVOMAP_PROXY_PORT": {
- "type": "string",
- "title": "Proxy port",
- "description": "Local Proxy mailbox port that the MCP bridge connects to.",
- "default": "19820"
- },
- "EVOLVE_STRATEGY": {
- "type": "string",
- "title": "Evolution strategy",
- "description": "balanced | innovate | harden | repair-only | early-stabilize | steady-state | auto",
- "default": "balanced",
- "enum": [
- "balanced",
- "innovate",
- "harden",
- "repair-only",
- "early-stabilize",
- "steady-state",
- "auto"
- ]
- }
- }
- }
+ "hooks": "./hooks/hooks.json"
}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 058edcf..c42d0af 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -16,4 +16,4 @@ jobs:
- name: Check out
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Run tests
- run: node --test test/*.test.js
+ run: node --test test/*.test.mjs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c7bc96f..ef05286 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,10 +5,38 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
-### Changed
-- MCP bridge is Recipe-first: `evolver_recipe_search` then `evolver_recipe_express`
- against Proxy `/recipe/search` and `/recipe/express`. `evolver_search_assets`
- remains as Gene/Capsule fallback when no Recipe hits.
+## [2.2.0] — 2026-10-26
+
+Aligned with the DeepSeek Harness plugin (dsh-evolver 0.3.0): Evolver stays out of the model context.
+
+### Added
+- `beforeSubmitPrompt` recalls one strategy by the prompt text and adds it as `additional_context`, ending with
+ `Apply them where they fit.`; inline edits (no `composer_mode`) are left alone.
+- `preToolUse` / `postToolUse` / `postToolUseFailure` / `afterFileEdit` / `afterAgentResponse` record the turn's
+ commands, checks (non-zero exit code, else failure output), paths, edits and answer; `stop` reports every
+ injected strategy with that evidence and the turn's own end (`completed` / `aborted` / `error`) as
+ `cursor::`.
+- Aborted reports when a chat closes mid-turn, and at the next session start for a chat whose Cursor process is
+ gone; correction/praise follow-ups, retention reports, a report outbox with retries, and local root-event records.
+- Session-end distillation: a verified turn that reused no strategy becomes the chat's candidate
+ (`~/.evolver/state/cursor-distill-pending.json`), dropped by a correction, and is distilled once in a detached
+ worker by the Cursor agent CLI, headless (`cursor-agent -p --output-format json --mode ask`, empty workspace,
+ on the chat's model), then sent to `POST /conversation/distill` with `platform: 'cursor'`. Three tries;
+ leftovers are claimed by the next session start. State writes go through a shared lock; every state file is
+ prefixed `cursor-`.
+- `/evolver-status`, `/evolver-search`, `/evolver-distill`, `/evolver-sync`, `/evolver-review`, `/evolver-run`
+ and `/evolver-solidify` are answered by the `beforeSubmitPrompt` hook itself, which stops the prompt before any
+ model sees it; `/evolver-evolve` is the one command left as a prompt.
+- Evolver is installed and its Proxy started in the background when missing or older than 2.0.44.
+
+### Removed
+- The `evolver-proxy` MCP server and its tools, the `evolver` skill, the `evolution-memory` rule and the
+ commands that told the model to call tools.
+- The session-start memory-graph summary and claim nudge, the `afterFileEdit` `[Evolution Signal]` notice, and
+ the `stop`/`sessionEnd` git-diff outcome written to the local memory graph and posted to the Hub's
+ `/a2a/evolution/record` (dsh keeps no equivalent; reuse reports and distillation replace it). The hooks no
+ longer run `git`.
+- The **Plugins → Configure** variables, which only fed the MCP server.
## [2.1.0] — 2026-08-18
diff --git a/README.md b/README.md
index b08c70d..2fcdb56 100644
--- a/README.md
+++ b/README.md
@@ -2,102 +2,40 @@
-Evolver — Agent Self-Evolving Engine
-
-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:** v2.1.0 — aligned with `@evomap/evolver` 2.x. Hooks + `evolver`
-> skill + commands + MCP bridge. Works standalone (local memory) and, when the
-> Proxy is running, exposes the EvoMap mailbox (recipes, then genes/capsules) as MCP tools.
-> Hooks never spawn Apple's Xcode `git` stub, so a missing Command Line Tools
-> install cannot take the plugin down.
-
-## 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. Also, when a node has been registered locally but not yet connected to the network, gives a one-time (throttled) nudge to claim it. |
-| `signal-detect.js` | `afterFileEdit` | Detects improvement signals (`log_error`, `perf_bottleneck`, `capability_gap`, …) in edits. |
-| `session-end.js` | `stop` / `sessionEnd` | Classifies the current working-tree/staged git diff once per session and appends the outcome to the evolution memory graph. Duplicate firings are suppressed. |
-
-It also ships:
-
-- An **`evolver` skill** describing the recall → work → record loop.
-- An **MCP bridge** (`evolver-proxy`) exposing the local Proxy mailbox as tools:
- `evolver_recipe_search` / `evolver_recipe_express` (Recipe-first), then
- `evolver_search_assets` as Gene/Capsule fallback, `evolver_status`,
- `evolver_fetch_asset`, `evolver_report_reuse`, `evolver_publish_asset`,
- `evolver_distill_conversation`, `evolver_poll`.
-- Slash commands: **`/evolve`** (checkpoint), **`/search`** (find network assets),
- **`/status`** (health), and engine wrappers **`/run`**, **`/solidify`**,
- **`/review`**, **`/sync`**, **`/distill`** (use the `@evomap/evolver` CLI when
- installed, else `npx -y @evomap/evolver@2`).
-- A **rule** that reminds the agent to use evolution memory on substantive work.
+Evolver — Cursor plugin
+
+Evolver for Cursor, powered by [Evolver](https://github.com/EvoMap/evolver) (`@evomap/evolver`) and
+[EvoMap](https://evomap.ai): before each prompt it recalls one proven strategy, grades how the turn reused it on
+evidence (the checks the turn ran, whether its edits were kept, your next reply), reports that back to the EvoMap
+network, and distills the chat's verified work into a new Gene when the chat closes.
+
+The plugin runs entirely from Cursor hooks. It ships **no MCP server, no skill and no rule**, so nothing of
+Evolver enters the model's context but the recalled steps, which end with `Apply them where they fit.` — no
+instruction to search, report or distill. Reports and distillation run on hook events; the `/evolver-*` commands
+are answered by the plugin itself, before any model sees them.
+
+## What runs when
+
+| Cursor hook | What the plugin does |
+| --- | --- |
+| `sessionStart` (a new chat) | Reports the previous chat's kept or reverted edits (retention) and the open strategies of a chat whose Cursor process is gone (aborted), starts Evolver if it is missing or too old, and distills leftover candidates — all in a detached background worker. Adds no context, except a one-off notice when it installed, upgraded or could not start Evolver. |
+| `beforeSubmitPrompt` | Answers a typed `/evolver-*` command itself (see [Commands](#commands)). Otherwise recalls by the prompt text (`POST /asset/fetch {text}` on the local Proxy) and adds the best strategy as `additional_context`, which Cursor sends with that same request; labels the previous turn's reuse from this prompt (correction or praise) and drops its candidate on a correction. Inline edits (Cmd-K), which carry no `composer_mode` and drop added context, are left alone. |
+| `preToolUse` (`Write`, `Delete`) | Hashes the file an edit is about to change, for the retention report. |
+| `postToolUse`, `postToolUseFailure` | Notes the turn's commands, checks (test, build, lint, type check) with pass or fail, and touched paths. |
+| `afterFileEdit` | Notes the text each edit wrote in. |
+| `afterAgentResponse` | Notes the turn's final answer. |
+| `stop` | Reports every strategy injected into the turn, with the turn's end as Cursor gives it (`completed`, `aborted`, `error`); makes the turn the chat's distillation candidate when it completed verified. Cursor sends the next prompt without waiting for this hook, so that prompt waits up to 3 s for the turn to settle before it labels the reuses or drops the candidate; a turn whose stop never comes is closed then as an error. |
+| `sessionEnd` (chat or window closed) | Reports a turn still running as aborted, and anything left unreported too; closes the candidate and starts its distillation in the background. Keeps the chat's turn counter, so a reopened chat never repeats a task id. |
+
+Each hook does nothing when it runs inside the plugin's own headless distillation (`EVOLVER_HEADLESS=1`), which
+loads this plugin too.
## Install
### From the Cursor Marketplace
-Search for **Evolver** in the Cursor plugin marketplace and install.
-
-Reload Cursor. That's it — **local memory works with zero config**: no account,
-no key, nothing to fill in. Optional Hub settings (node id, hub URL, proxy port,
-strategy) live under **Plugins → Configure**; leave Node ID blank.
-
-### Plugin failed to load (`git init` / `xcode-select`)
-
-Cursor's plugin installer itself runs `git` when it copies the plugin into
-`~/.cursor/plugins/cache`. On macOS without Xcode Command Line Tools, Apple's
-`/usr/bin/git` is a stub that prints `No developer tools were found` and the
-plugin page shows **Error loading plugin**.
-
-Fix the installer side (pick one):
-
-```bash
-xcode-select --install
-# or
-brew install git
-```
-
-Then restart Cursor and reinstall / refresh the plugin.
-
-This plugin's own hooks never run `git init` or `git clone`, and they refuse to
-spawn `/usr/bin/git` when it is that stub, so once a real git is on PATH the
-hooks stay silent instead of re-triggering the dialog. Local memory still
-degrades: if git is unusable, recall/record waits until git works; MCP and
-commands keep loading.
-
-### Connecting to the EvoMap network (optional)
-
-The network layer (searching/reusing genes & capsules) is opt-in. To connect:
-
-1. **Leave any "Node ID" setting blank.** Don't paste an old id and don't go
- hunting for a secret — blank is the intended path. On first run the local
- Proxy registers a fresh node for you and prints a link to claim it; you never
- paste an id or a secret. Only point the install at a node you already run
- yourself if you have a specific reason to.
-2. Install the engine and run it once inside a git repo:
-
- ```bash
- npm i -g @evomap/evolver@2
- evolver
- ```
-
- The first run registers a fresh node for you and prints a **claim link**.
-3. Open that link while signed in to [evomap.ai](https://evomap.ai) to claim the
- node. Check status any time with `/status`.
-
-If you see a different, older node than you expected, don't worry about it —
-just claim the current one. Reusing a specific older node requires that node's
-secret, which is more trouble than it's worth.
+Search for **Evolver** in the Cursor plugin marketplace and install, then reload Cursor. **Local memory works
+with zero config** — no account, no key, no node id.
### Local development
@@ -106,83 +44,111 @@ 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
+Reload the window; Settings → Hooks lists the plugin's hooks.
-- **Node.js** (the hooks are Node scripts; Cursor invokes them via `node`).
-- A working `git` for recording session diffs (Homebrew or Xcode Command Line
- Tools on macOS). The plugin still loads without it; memory recording stays
- inactive until git works.
+## Prerequisites
-## Modes
+- Cursor with hooks (3.x). `beforeSubmitPrompt`'s `additional_context`, which carries the recalled strategy, is
+ read by Cursor 3.27 but not yet in Cursor's hook docs.
+- Node.js 20 or newer on the `PATH` Cursor's hooks see.
+- Evolver 2.0.44 or newer. The plugin installs it under `~/.evolver/state/cursor-engine` and starts its Proxy in
+ the background when it is missing; `npm install -g @evomap/evolver@latest` works too.
+- For session-end distillation: the Cursor agent CLI (`cursor-agent`, installed by
+ `curl https://cursor.com/install -fsS | bash`), signed in with `cursor-agent login` or `CURSOR_API_KEY`.
+ Without it the candidate stays on disk and nothing else changes.
-### Local mode (default, zero config)
+The plugin reads the live Proxy URL and token from `~/.evolver/settings.json` (written by the Proxy), else
+`http://127.0.0.1:19820` (`EVOMAP_PROXY_PORT` changes the port).
-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.**
+### Connecting to the EvoMap network (optional)
-### Full engine
+1. **Leave any node id blank.** The first run registers a fresh node for you and prints a **claim link**.
+2. Open that link while signed in to [evomap.ai](https://evomap.ai). `/evolver-status` shows a pending link.
+
+## Commands
+
+Cursor commands are prompts for the model, so the plugin answers its commands itself: when a prompt starts with
+one, the `beforeSubmitPrompt` hook runs it and stops the prompt with the answer (`continue: false`,
+`user_message`), and no model sees either. The command files in `commands/` exist for the slash menu.
+
+| Command | What it does |
+| --- | --- |
+| `/evolver-status` | The Proxy, engine setup, the latest recall, recent reuse reports with the Hub's verdict, waiting candidates, claim link. |
+| `/evolver-search ` | Searches the EvoMap network (`POST /asset/search`). |
+| `/evolver-distill [focus]` | Distills this chat's latest verified turn now, through the Cursor agent CLI. |
+| `/evolver-evolve [focus]` | An evolution checkpoint for the agent, dsh's text as is (Cursor appends the focus): the only command that is a prompt. It is recalled for like any prompt, and is no verdict on the turn before it, whose reuses wait for the next prompt. |
+| `/evolver-sync`, `/evolver-review`, `/evolver-run`, `/evolver-solidify` | Run the `evolver` CLI (`npx -y @evomap/evolver@^2.0.44` when it is not installed) with the arguments quoted as a shell would, ten minutes at most. |
+
+Cursor shows a command's answer in the chat as a message the hook blocked the prompt with.
+
+## Session-end distillation
+
+A turn that ended `completed`, used tools, passed its last check and reused no injected strategy leaves a
+candidate in `~/.evolver/state/cursor-distill-pending.json`; a later verified turn replaces it, and a next prompt
+that reads as a correction drops it (a distillation already running sees that and stops). When the chat closes,
+a detached worker runs the Cursor agent CLI headless on the chat's model —
+`cursor-agent -p --output-format json --mode ask --trust --workspace --model `,
+read-only, without `--force` so nothing is applied — to write a Gene from a digest of that turn (task, commands,
+touched paths, checks, final answer — never the whole history), and sends it to the Proxy's
+`POST /conversation/distill` with `platform: 'cursor'`, `trigger: 'session_end'`, `persist: true` and
+`publish: false`. The CLI reports no token usage, so no `distill_usage` is sent. Keys and tokens are stripped from
+the candidate first, paths and a manual focus included.
+
+A candidate stays on disk until it is distilled; three failed tries (no `cursor-agent`, not signed in, an
+unparsed answer, a Proxy error) give it up. One whose chat is over — closed, or left by a Cursor process that is
+gone — is claimed by the next session start, one process at a time, while an idle chat keeps its own.
+
+**Owner.** Cursor starts hooks from its extension host (the editor) or its own process (the CLI), sometimes through
+a shell. The owner recorded with a candidate or an injection is the nearest ancestor process whose executable path
+(from `ps`, or `/proc//exe` on Linux) names Cursor; it goes away with the window or CLI that runs the chat (a
+window reload counts). Where no process table can be read (Windows) no owner is recorded, and such entries are only
+reported or distilled once their chat closes.
+
+**Windows.** Only a `cursor-agent.exe` (or an `agent.exe` inside the CLI's install) is run, without a shell: a
+`.cmd` shim would need `cmd.exe`, whose 8191-character command line the prompt argument overruns. With neither, the
+candidate is kept and given up after three tries.
+
+## Reuse reports
+
+Every strategy injected into a turn is reported through the Proxy's `POST /asset/reuse-result` as
+`{asset_id, outcome, task_id, reason, evidence}`:
+
+| Report | When | `task_id` | `evidence` |
+|---|---|---|---|
+| Turn end | `stop`, or the next prompt after a turn whose `stop` never came | `cursor::` | `tier` `L1` when the turn ran a test, build, lint or type check, else `none`; `applied`; `check` `{kind, passed, fail_then_pass}` of the last check; `aborted`; `stage: immediate` |
+| Correction | the next prompt says the answer did not hold, or approves it | `…:correction` | `tier: L3`, `applied`, `stage: correction` |
+| Retention | the next chat in the same project finds the turn's edits kept or reverted | `…:retention` | `tier: L2`, `applied`, `stage: retention` |
+| Aborted | the chat closes, or its Cursor process goes away, before the turn ends (those of a process that is gone go out at the next session start) | `cursor::` | `tier: none`, `applied: partial`, `aborted: true`, `stage: immediate` |
+
+- **`applied`** compares the strategy's numbered steps with the turn's shell commands, touched paths, the text its
+ edits wrote in (200,000 characters a turn) and the final answer: `used` when half the steps show up, `partial`
+ when fewer do, `unused` when none do. A turn that left nothing to compare is sent as tier `none` with
+ `applied: partial`, and its reason says `applied: unknown`.
+- **Checks** are `Shell` commands that run tests, builds, linters or type checkers. A non-zero exit code
+ (`postToolUse`'s `exitCode`, or a `postToolUseFailure` for an error or a timeout) fails one; with exit code 0 the
+ output is read too, since a failing suite piped through `tail` exits zero. A command the person interrupted has no
+ verdict, and one that was denied never ran. A command moved to the background before it finished has no verdict
+ either where Cursor fails it with `Unknown error`; its streaming Shell executor instead reports such a command as
+ exit code 0 with the output so far and nothing to tell it apart, so there the output alone decides.
+- **Delivery.** Reports wait in `~/.evolver/state/cursor-reports-outbox.json` and are retried by the background
+ worker at every turn end, follow-up prompt and session start for 8 days. One the Hub refuses as invalid is
+ settled as rejected and never resent. Reports with evidence are also written once to the local root event log.
+- Reasons name the turn, the applied verdict and the check kind, never the prompt, a command or code, and the
+ prompt loses keys and tokens before recall sends it.
+
+Every state file is prefixed `cursor-`, so another host's Evolver plugin never claims or reports this one's.
+
+## Develop
```bash
-npm install -g @evomap/evolver@2
+npm test
```
-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="…"
-```
+## Uninstall
-The `stop` hook will then record outcomes to the Hub (with a local fallback if
-the Hub is unreachable) using Node's built-in `fetch`, so the API key is not
-exposed in process arguments. See the [evolver docs](https://evomap.ai) for
-node registration.
-
-## Architecture (the MCP bridge vs. gep-mcp-server)
-
-- **This plugin's `evolver-proxy` bridge** is a thin, MIT, zero-dependency glue
- that exposes the *local* Proxy mailbox (the genes/capsules already synced to
- your machine) as MCP tools, reading the live url + auth token from
- `~/.evolver/settings.json`. It degrades gracefully when the Proxy is down.
-- **`@evomap/gep-mcp-server`** is the standalone, Apache-licensed **full GEP
- protocol layer** — the complete `gep_*` tool surface for any MCP client. Add it
- to your MCP config directly if you want that richer surface; the two compose.
-- **`@evomap/evolver`** is the GPL-licensed engine (daemon + CLI). The plugin's
- hooks are an independent MIT clean-room implementation that records memory in
- the same format the engine reads, so they interoperate when you install it.
-
-## Environment variables
-
-| Variable | Default | Purpose |
-|---|---|---|
-| `MEMORY_GRAPH_PATH` | (auto) | Override the memory graph file location. |
-| `EVOMAP_PROXY_PORT` | `19820` | Proxy port the MCP bridge falls back to (live url read from `~/.evolver/settings.json`). Also settable under **Plugins → Configure**. |
-| `EVOMAP_HUB_URL` / `EVOMAP_API_KEY` / `EVOMAP_NODE_ID` | (unset) | Enable Hub recording. Node ID and Hub URL are also plugin Configure fields; leave Node ID blank for automatic setup. |
-| `EVOLVE_STRATEGY` | `balanced` | Default strategy for `/run`. Plugin Configure field. |
-| `EVOLVER_GIT_BINARY` | (auto) | Absolute path to git. Set empty to force "git unavailable" (hooks will not spawn git). |
-| `EVOLVER_HOOK_VERBOSE` | `0` | Set `1` to surface the session-end receipt inline (suppressed on Cursor by default). |
+Remove the plugin from Cursor's plugin settings. Local Evolver memory under `~/.evolver/` is left intact.
## 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`.
+MIT © EvoMap. Installing `@evomap/evolver` (GPL) is a separate step the plugin performs as a package install; its
+code is not part of this repository. See `LICENSE`.
diff --git a/commands/distill.md b/commands/distill.md
deleted file mode 100644
index d3f5863..0000000
--- a/commands/distill.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-name: distill
-description: Distill a reusable skill/gene from recent run history (optionally from an LLM response file).
----
-
-Distill Evolver run history into a reusable skill/gene.
-
-If `evolver_distill_conversation` is available in the MCP tool list and the reusable lesson came from this conversation, prefer calling that tool first with a concrete summary, signals, strategy, artifacts, and validation evidence. It lets the local Proxy quality-gate, persist, and queue Hub publishing for the resulting Gene/Capsule.
-
-```bash
-EVOLVER="evolver"; command -v evolver >/dev/null 2>&1 || EVOLVER="npx -y @evomap/evolver@2"
-$EVOLVER distill $ARGUMENTS
-```
-
-Explain to the user what was distilled (the candidate skill/gene and the signals it generalizes), and remind them that only assets produced through genuine Evolver self-evolution are eligible to be published to the EvoMap skill store via `/sync`.
diff --git a/commands/evolve.md b/commands/evolve.md
deleted file mode 100644
index 40f1c65..0000000
--- a/commands/evolve.md
+++ /dev/null
@@ -1,32 +0,0 @@
----
-name: evolve
-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@2` 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/commands/evolver-distill.md b/commands/evolver-distill.md
new file mode 100644
index 0000000..d1cfc61
--- /dev/null
+++ b/commands/evolver-distill.md
@@ -0,0 +1,7 @@
+---
+description: "Distill this chat's latest verified turn into a reusable Gene now, instead of at session end."
+---
+
+The Evolver plugin answers `/evolver-distill [focus]` itself, from its beforeSubmitPrompt hook, before this prompt reaches you.
+
+If you are reading this, that hook did not run. Do not run any tool for it: tell the user that the Evolver plugin's hooks are not active in this Cursor window (Settings → Hooks shows them) and that reloading the window or reinstalling the plugin brings them back.
diff --git a/commands/evolver-evolve.md b/commands/evolver-evolve.md
new file mode 100644
index 0000000..159fd4f
--- /dev/null
+++ b/commands/evolver-evolve.md
@@ -0,0 +1,17 @@
+---
+description: Run an evolution checkpoint — recall relevant past outcomes, reflect on the current task, and record what was learned.
+---
+
+Take a deliberate evolution step for the current task.
+
+1. **Recall.** Look at the evolution memory this plugin injected behind the prompt, or read
+ the tail of the full engine's memory graph — `~/.evolver/memory/evolution/memory_graph.jsonl`, or the
+ project's `memory/evolution/memory_graph.jsonl` when it exists. Summarize any recent
+ outcome — success or failure — relevant to what we're working on.
+
+2. **Reflect.** Given the current diff and task state, say in one or two lines: what
+ worked, what didn't, and what the durable lesson is.
+
+Reuse of an injected strategy is reported at every turn end, and the session's verified work is
+distilled when the session ends; `/evolver-distill` distills it now. Keep this lightweight — it is an
+explicit checkpoint, not a ceremony on every turn.
diff --git a/commands/evolver-review.md b/commands/evolver-review.md
new file mode 100644
index 0000000..5bb6e8c
--- /dev/null
+++ b/commands/evolver-review.md
@@ -0,0 +1,7 @@
+---
+description: "Review Evolver's pending evolved changes, then approve or reject them (evolver review)."
+---
+
+The Evolver plugin answers `/evolver-review [--approve | --reject]` itself, from its beforeSubmitPrompt hook, before this prompt reaches you.
+
+If you are reading this, that hook did not run. Do not run any tool for it: tell the user that the Evolver plugin's hooks are not active in this Cursor window (Settings → Hooks shows them) and that reloading the window or reinstalling the plugin brings them back.
diff --git a/commands/evolver-run.md b/commands/evolver-run.md
new file mode 100644
index 0000000..9cd237c
--- /dev/null
+++ b/commands/evolver-run.md
@@ -0,0 +1,7 @@
+---
+description: "Run one Evolver self-evolution cycle on this repository (evolver run)."
+---
+
+The Evolver plugin answers `/evolver-run [--loop] [--dry-run] [--strategy=…]` itself, from its beforeSubmitPrompt hook, before this prompt reaches you.
+
+If you are reading this, that hook did not run. Do not run any tool for it: tell the user that the Evolver plugin's hooks are not active in this Cursor window (Settings → Hooks shows them) and that reloading the window or reinstalling the plugin brings them back.
diff --git a/commands/evolver-search.md b/commands/evolver-search.md
new file mode 100644
index 0000000..d603f91
--- /dev/null
+++ b/commands/evolver-search.md
@@ -0,0 +1,7 @@
+---
+description: "Search the EvoMap network for reusable assets."
+---
+
+The Evolver plugin answers `/evolver-search ` itself, from its beforeSubmitPrompt hook, before this prompt reaches you.
+
+If you are reading this, that hook did not run. Do not run any tool for it: tell the user that the Evolver plugin's hooks are not active in this Cursor window (Settings → Hooks shows them) and that reloading the window or reinstalling the plugin brings them back.
diff --git a/commands/evolver-solidify.md b/commands/evolver-solidify.md
new file mode 100644
index 0000000..66dfe26
--- /dev/null
+++ b/commands/evolver-solidify.md
@@ -0,0 +1,7 @@
+---
+description: "Solidify the working changes into a durable Gene or Capsule (evolver solidify)."
+---
+
+The Evolver plugin answers `/evolver-solidify [--dry-run] [--intent=…] [--summary=…]` itself, from its beforeSubmitPrompt hook, before this prompt reaches you.
+
+If you are reading this, that hook did not run. Do not run any tool for it: tell the user that the Evolver plugin's hooks are not active in this Cursor window (Settings → Hooks shows them) and that reloading the window or reinstalling the plugin brings them back.
diff --git a/commands/evolver-status.md b/commands/evolver-status.md
new file mode 100644
index 0000000..9908b33
--- /dev/null
+++ b/commands/evolver-status.md
@@ -0,0 +1,7 @@
+---
+description: "Show Evolver health: the Proxy, engine setup, recent recalls, reuse reports and distillation candidates."
+---
+
+The Evolver plugin answers `/evolver-status` itself, from its beforeSubmitPrompt hook, before this prompt reaches you.
+
+If you are reading this, that hook did not run. Do not run any tool for it: tell the user that the Evolver plugin's hooks are not active in this Cursor window (Settings → Hooks shows them) and that reloading the window or reinstalling the plugin brings them back.
diff --git a/commands/evolver-sync.md b/commands/evolver-sync.md
new file mode 100644
index 0000000..ea45412
--- /dev/null
+++ b/commands/evolver-sync.md
@@ -0,0 +1,7 @@
+---
+description: "Sync assets between the local store and the EvoMap Hub (evolver sync)."
+---
+
+The Evolver plugin answers `/evolver-sync [--scope=…] [--type=…] [--dry-run]` itself, from its beforeSubmitPrompt hook, before this prompt reaches you.
+
+If you are reading this, that hook did not run. Do not run any tool for it: tell the user that the Evolver plugin's hooks are not active in this Cursor window (Settings → Hooks shows them) and that reloading the window or reinstalling the plugin brings them back.
diff --git a/commands/review.md b/commands/review.md
deleted file mode 100644
index 84f78ec..0000000
--- a/commands/review.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-name: review
-description: Review Evolver's pending evolved changes, then approve (solidify) or reject (roll back).
----
-
-Review the changes Evolver currently has **pending solidify** in this repository.
-
-1. First show the user what is pending — run `git status --short` and `git diff` (or `git diff HEAD`) so they can see the actual proposed edits.
-2. Resolve the CLI:
-
-```bash
-EVOLVER="evolver"; command -v evolver >/dev/null 2>&1 || EVOLVER="npx -y @evomap/evolver@2"
-```
-
-3. Then act on the user's intent:
- - If the user passed `--approve` (or asked to accept): run `$EVOLVER review --approve` to solidify.
- - If the user passed `--reject` (or asked to discard): run `$EVOLVER review --reject` to roll back.
- - If no flag was given via `$ARGUMENTS`, summarize the pending diff and **ask the user** whether to approve or reject before running anything.
-
-Report the final state (solidified / rolled back) and the resulting git status.
diff --git a/commands/run.md b/commands/run.md
deleted file mode 100644
index 19dbc6f..0000000
--- a/commands/run.md
+++ /dev/null
@@ -1,19 +0,0 @@
----
-name: run
-description: Run one Evolver self-evolution cycle on the current repo (collect signals → select/mutate genes → propose changes).
----
-
-Run an Evolver evolution cycle in the **current git repository**.
-
-Steps:
-1. Confirm we're in a git repo (`git rev-parse --is-inside-work-tree`). If not, tell the user Evolver requires git and stop.
-2. Resolve the CLI and run it, passing through the user's flags. Execute:
-
-```bash
-EVOLVER="evolver"; command -v evolver >/dev/null 2>&1 || EVOLVER="npx -y @evomap/evolver@2"
-EVOLVE_STRATEGY="${EVOLVE_STRATEGY:-balanced}" $EVOLVER run $ARGUMENTS
-```
-
-3. Summarize what changed: which signals were collected, which gene was selected/mutated, and whether any changes are now **pending solidify**. If changes are pending, remind the user they can inspect and accept them with `/review` (or roll back with `/review --reject`).
-
-Do not auto-approve pending changes — leave that to the user via `/review`.
diff --git a/commands/search.md b/commands/search.md
deleted file mode 100644
index efc20ef..0000000
--- a/commands/search.md
+++ /dev/null
@@ -1,25 +0,0 @@
----
-name: search
-description: Search the EvoMap network for reusable Recipes first (ordered Gene/Capsule DNA), via the evolver-proxy MCP tools. Gene/Capsule search is fallback.
----
-
-# /search — search EvoMap
-
-Before doing substantive work from scratch, search the network for a Recipe.
-
-Treat the arguments as a free-text query describing the current task (preferred),
-or as space-separated signal keywords (e.g. `log_error perf_bottleneck`). If none
-are given, infer 2–4 keywords from the current task. Valid gene/capsule fallback
-signals: `log_error`, `perf_bottleneck`, `test_failure`, `capability_gap`,
-`user_feature_request`, `deployment_issue`, `recurring_error`.
-
-1. Call `evolver_recipe_search` (from the `evolver-proxy` server) with `q` set to
- the task. Omit `q` to list published Recipes.
-2. If a Recipe hit applies, call `evolver_recipe_express` with its `recipeId`.
- Hub unfolds Gene then Capsule steps; do not parse recipe JSON locally.
-3. Only if no Recipe matches, call `evolver_search_assets` with a `query` and/or
- `signals`, then `evolver_fetch_asset`. After you actually reuse a fetched
- Gene/Capsule, call `evolver_report_reuse` with those asset IDs.
-
-If the tool reports the Proxy is unreachable, tell the user to run `evolver` once
-in a git repo to start it — the local memory hooks keep working regardless.
diff --git a/commands/solidify.md b/commands/solidify.md
deleted file mode 100644
index 11cdfec..0000000
--- a/commands/solidify.md
+++ /dev/null
@@ -1,19 +0,0 @@
----
-name: solidify
-description: Solidify the current working changes into a durable Evolver gene/capsule (with rollback safety).
----
-
-Solidify the current working-tree changes into a durable Evolver asset.
-
-1. Ensure we're in a git repo and show `git diff --stat` so the user sees what will be captured.
-2. Resolve the CLI and run solidify with the user's flags:
-
-```bash
-EVOLVER="evolver"; command -v evolver >/dev/null 2>&1 || EVOLVER="npx -y @evomap/evolver@2"
-$EVOLVER solidify $ARGUMENTS
-```
-
-3. If the user did not provide `--summary`, infer a concise one-line summary of the change from the diff and pass it as `--summary="..."`.
-4. Report the gene/capsule that was created or updated, and whether a rollback point was recorded.
-
-Tip: pass `--dry-run` first to preview without writing.
diff --git a/commands/status.md b/commands/status.md
deleted file mode 100644
index 7888192..0000000
--- a/commands/status.md
+++ /dev/null
@@ -1,49 +0,0 @@
----
-name: status
-description: Show Evolver health — Proxy/MCP status, evolution memory, workspace id, and whether the full engine is installed.
----
-
-Report Evolver health as a short checklist.
-
-1. **Proxy / MCP** — call the `evolver_status` MCP tool (from the `evolver-proxy`
- server). If it returns status, show `node_id`, `outbound_pending`,
- `inbound_pending`, `last_sync_at`. If it errors, the Proxy is down — note it
- starts when you run `evolver` once in a git repo, and that the local memory
- hooks keep working regardless.
-
- Then translate the Proxy state into plain "are you connected?" language for
- the user — **don't** dump raw error JSON or internal terms like
- `node_secret`, `stake`, or `hub_rotate`:
-
- - If `~/.evomap/claim_url` exists, the node is registered but **not yet
- claimed**. Tell the user to sign in to evomap.ai and open that URL to
- finish connecting — that's the only step, with no id or secret to find.
-
- ```bash
- [ -f ~/.evomap/claim_url ] && echo "not yet connected — sign in to evomap.ai and open: $(cat ~/.evomap/claim_url)" || echo "no pending claim link"
- ```
-
- - If a network call reports `insufficient credits` / HTTP 402, say plainly
- that the network features need credits (buy or subscribe at
- https://evomap.ai/pricing); local memory keeps working as usual.
-
-2. **Evolution memory** — does the local graph exist and how many outcomes?
-
-```bash
-F=~/.evolver/memory/evolution/memory_graph.jsonl
-[ -f "$F" ] && echo "memory graph: $F ($(wc -l < "$F" | tr -d ' ') outcomes)" || echo "no local evolution memory yet (appears after a session ends with changes in a git repo)"
-```
-
-3. **This workspace's id** — the forge-resistant scoping key (only in a git repo):
-
-```bash
-R=$(git rev-parse --show-toplevel 2>/dev/null); [ -n "$R" ] && { [ -f "$R/.evolver/workspace-id" ] && echo "workspace-id: present" || echo "workspace-id: not yet created"; } || echo "not a git repo — memory inactive here"
-```
-
-4. **Full engine (optional)** — is the `@evomap/evolver` CLI installed?
-
-```bash
-command -v evolver >/dev/null 2>&1 && evolver --version 2>/dev/null | head -1 || echo "evolver CLI not installed — hooks + MCP still work; 'npm i -g @evomap/evolver@2' unlocks /run etc."
-```
-
-Finish with one line on overall readiness.
diff --git a/commands/sync.md b/commands/sync.md
deleted file mode 100644
index 3167025..0000000
--- a/commands/sync.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-name: sync
-description: Sync evolution assets (genes/capsules) between the local store and the EvoMap Hub.
----
-
-Sync Evolver assets with the EvoMap Hub.
-
-```bash
-EVOLVER="evolver"; command -v evolver >/dev/null 2>&1 || EVOLVER="npx -y @evomap/evolver@2"
-$EVOLVER sync $ARGUMENTS
-```
-
-After it runs, summarize: how many assets were pulled/updated, any local-only (unpublished) assets it listed, and — if `--export` was given — where the `.gepx` archive was written.
-
-If it reports the node identity or Hub credentials are missing, point the user to `/status` and the README's *EvoMap Hub* section to diagnose.
diff --git a/hooks/_filter.js b/hooks/_filter.js
deleted file mode 100644
index d36a8fa..0000000
--- a/hooks/_filter.js
+++ /dev/null
@@ -1,65 +0,0 @@
-// 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/hooks/_paths.js b/hooks/_paths.js
deleted file mode 100644
index e1da6de..0000000
--- a/hooks/_paths.js
+++ /dev/null
@@ -1,529 +0,0 @@
-// 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;
-
-const GIT_PROBE_TIMEOUT_MS = 2000;
-const GIT_TIMEOUT_MS = 5000;
-const GIT_MAX_BUFFER = 10 * 1024 * 1024;
-
-// Cached result of git-binary resolution. `undefined` = not yet probed;
-// `null` = git must not be spawned (missing, or macOS Xcode stub);
-// string = argv0 to pass to spawnSync.
-let resolvedGitBinary = undefined;
-
-const DARWIN_GIT_FALLBACKS = ['/opt/homebrew/bin/git', '/usr/local/bin/git'];
-const FORBIDDEN_GIT_SUBCOMMANDS = new Set(['init', 'clone', 'daemon']);
-// Global git options that consume the following argv entry. Needed so
-// `git -c credential.interactive=false init` is still recognized as `init`
-// (Cursor's plugin installer uses this shape).
-const GIT_OPTIONS_WITH_VALUE = new Set([
- '-c',
- '-C',
- '-o',
- '--git-dir',
- '--work-tree',
- '--namespace',
- '--config-env',
-]);
-
-function gitSubcommand(args) {
- for (let i = 0; i < args.length; i += 1) {
- const arg = args[i];
- if (typeof arg !== 'string') {
- continue;
- }
- if (arg === '--') {
- const next = args[i + 1];
- return typeof next === 'string' ? next : '';
- }
- if (GIT_OPTIONS_WITH_VALUE.has(arg)) {
- i += 1;
- continue;
- }
- if (arg.startsWith('-')) {
- continue;
- }
- return arg;
- }
- return '';
-}
-
-/**
- * Return true when `candidate` is a string pointing at an existing regular file.
- */
-function looksLikeFile(candidate) {
- if (typeof candidate !== 'string' || candidate.length === 0) {
- return false;
- }
- try {
- return fs.statSync(candidate).isFile();
- } catch (_err) {
- return false;
- }
-}
-
-/**
- * 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;
- }
-}
-
-/**
- * Reset the cached git-binary probe. Exported for tests only.
- */
-function _resetGitBinaryCache() {
- resolvedGitBinary = undefined;
-}
-
-/**
- * Look up `cmd` on PATH without executing it. Returns an absolute path or null.
- */
-function lookUpOnPath(cmd) {
- if (typeof cmd !== 'string' || cmd.length === 0) {
- return null;
- }
- const pathVar = process.env.PATH || '';
- const sep = process.platform === 'win32' ? ';' : ':';
- const exts =
- process.platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
- for (const dir of pathVar.split(sep)) {
- if (!dir) {
- continue;
- }
- for (const ext of exts) {
- const candidate = path.join(dir, cmd + ext);
- if (looksLikeFile(candidate)) {
- return candidate;
- }
- }
- }
- return null;
-}
-
-/**
- * True when macOS has no developer directory configured, so `/usr/bin/git` is
- * the Xcode stub that prints "No developer tools were found" and pops the
- * install dialog. `xcode-select -p` reports this without triggering the GUI.
- */
-function darwinDeveloperDirMissing() {
- if (process.platform !== 'darwin') {
- return false;
- }
- try {
- const result = spawnSync('xcode-select', ['-p'], {
- shell: false,
- timeout: GIT_PROBE_TIMEOUT_MS,
- encoding: 'utf8',
- stdio: ['ignore', 'pipe', 'pipe'],
- });
- return result.status !== 0;
- } catch (_err) {
- return true;
- }
-}
-
-/**
- * Resolve a git binary that is safe to spawn. Never returns `/usr/bin/git`
- * when that path is the Xcode CLT stub — spawning it is what produces the
- * Cursor "Error loading plugin" / `git init` + xcode-select failure.
- *
- * Override: set `EVOLVER_GIT_BINARY` to an absolute path, or to empty to
- * force "git unavailable".
- *
- * @returns {string|null}
- */
-function resolveGitBinary() {
- if (resolvedGitBinary !== undefined) {
- return resolvedGitBinary;
- }
-
- if (Object.prototype.hasOwnProperty.call(process.env, 'EVOLVER_GIT_BINARY')) {
- const override = process.env.EVOLVER_GIT_BINARY;
- resolvedGitBinary =
- typeof override === 'string' && override.length > 0 ? override : null;
- return resolvedGitBinary;
- }
-
- const pathGit = lookUpOnPath('git');
-
- if (darwinDeveloperDirMissing()) {
- if (pathGit && pathGit !== '/usr/bin/git') {
- resolvedGitBinary = pathGit;
- return resolvedGitBinary;
- }
- for (const candidate of DARWIN_GIT_FALLBACKS) {
- if (looksLikeFile(candidate)) {
- resolvedGitBinary = candidate;
- return resolvedGitBinary;
- }
- }
- resolvedGitBinary = null;
- return null;
- }
-
- resolvedGitBinary = pathGit || 'git';
- return resolvedGitBinary;
-}
-
-/**
- * Spawn git with non-interactive env. Never runs `init` / `clone`.
- * Returns { status, stdout, stderr }. status is 1 on any failure, including
- * "git is not available".
- */
-function runGit(args, cwd, options) {
- const opts = options && typeof options === 'object' ? options : {};
- if (!Array.isArray(args) || args.length === 0) {
- return { status: 1, stdout: '', stderr: 'git: missing arguments' };
- }
- const sub = gitSubcommand(args);
- if (FORBIDDEN_GIT_SUBCOMMANDS.has(sub)) {
- return {
- status: 1,
- stdout: '',
- stderr: `evolver hooks never run git ${sub}`,
- };
- }
-
- const bin = resolveGitBinary();
- if (!bin) {
- return { status: 1, stdout: '', stderr: 'git is not available' };
- }
-
- try {
- const result = spawnSync(bin, args, {
- cwd: looksLikeDir(cwd) ? cwd : undefined,
- shell: false,
- timeout: typeof opts.timeout === 'number' ? opts.timeout : GIT_TIMEOUT_MS,
- maxBuffer:
- typeof opts.maxBuffer === 'number' ? opts.maxBuffer : GIT_MAX_BUFFER,
- encoding: 'utf8',
- stdio: ['ignore', 'pipe', 'pipe'],
- env: Object.assign({}, process.env, {
- GIT_TERMINAL_PROMPT: '0',
- GIT_ASKPASS: '',
- }),
- });
- return {
- status: typeof result.status === 'number' ? result.status : 1,
- stdout: typeof result.stdout === 'string' ? result.stdout : '',
- stderr: typeof result.stderr === 'string' ? result.stderr : '',
- };
- } catch (_err) {
- return { status: 1, stdout: '', stderr: '' };
- }
-}
-
-/**
- * Diagnose git usability for a directory without spawning the Xcode stub.
- */
-function gitUsability(dir) {
- const repoRoot = findRepoRoot(dir);
- const binary = resolveGitBinary();
- return {
- hasRepo: repoRoot !== null,
- gitBinary: binary,
- usable: repoRoot !== null && binary !== null,
- };
-}
-
-/**
- * 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.
- *
- * Uses a filesystem walk for `.git` (directory or worktree file) so we never
- * spawn git — on macOS without Xcode CLT, spawning `/usr/bin/git` pops the
- * developer-tools dialog and can surface as Cursor's "Error loading plugin".
- */
-function isGitWorkspace(dir) {
- return findRepoRoot(dir) !== null;
-}
-
-/**
- * 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,
- findRepoRoot,
- findMemoryGraph,
- resolveWorkspaceId,
- resolveGitBinary,
- runGit,
- gitUsability,
- _resetGitBinaryCache,
-};
diff --git a/hooks/_signals.js b/hooks/_signals.js
deleted file mode 100644
index 056e86e..0000000
--- a/hooks/_signals.js
+++ /dev/null
@@ -1,120 +0,0 @@
-// 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/hooks/after-agent-response.mjs b/hooks/after-agent-response.mjs
new file mode 100644
index 0000000..fb412fa
--- /dev/null
+++ b/hooks/after-agent-response.mjs
@@ -0,0 +1,5 @@
+#!/usr/bin/env node
+import { runRecordingHook } from '../lib/recording-hook.mjs';
+import { noteAnswer } from '../lib/turn-record.mjs';
+
+runRecordingHook((sessionId, turnId, input) => noteAnswer(sessionId, turnId, input.text));
diff --git a/hooks/after-file-edit.mjs b/hooks/after-file-edit.mjs
new file mode 100644
index 0000000..ad87a23
--- /dev/null
+++ b/hooks/after-file-edit.mjs
@@ -0,0 +1,5 @@
+#!/usr/bin/env node
+import { runRecordingHook } from '../lib/recording-hook.mjs';
+import { noteFileEdit } from '../lib/turn-record.mjs';
+
+runRecordingHook(noteFileEdit);
diff --git a/hooks/hooks.json b/hooks/hooks.json
index 0290c7d..ea76519 100644
--- a/hooks/hooks.json
+++ b/hooks/hooks.json
@@ -2,30 +2,31 @@
"version": 1,
"hooks": {
"sessionStart": [
- {
- "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/session-start.js\"",
- "timeout": 3
- }
+ { "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/session-start.mjs\"", "timeout": 10 }
+ ],
+ "beforeSubmitPrompt": [
+ { "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/user-prompt-submit.mjs\"", "timeout": 660 }
+ ],
+ "preToolUse": [
+ { "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/pre-tool-use.mjs\"", "matcher": "^(Write|Delete)$", "timeout": 5 }
+ ],
+ "postToolUse": [
+ { "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/post-tool-use.mjs\"", "timeout": 5 }
+ ],
+ "postToolUseFailure": [
+ { "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/post-tool-use-failure.mjs\"", "timeout": 5 }
],
"afterFileEdit": [
- {
- "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/signal-detect.js\"",
- "timeout": 2
- }
+ { "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/after-file-edit.mjs\"", "timeout": 5 }
+ ],
+ "afterAgentResponse": [
+ { "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/after-agent-response.mjs\"", "timeout": 5 }
],
"stop": [
- {
- "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/session-end.js\"",
- "timeout": 8,
- "loop_limit": 1
- }
+ { "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/stop.mjs\"", "timeout": 10 }
],
"sessionEnd": [
- {
- "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/session-end.js\"",
- "timeout": 8,
- "loop_limit": 1
- }
+ { "command": "node \"${CURSOR_PLUGIN_ROOT}/hooks/session-end.mjs\"", "timeout": 10 }
]
}
}
diff --git a/hooks/post-tool-use-failure.mjs b/hooks/post-tool-use-failure.mjs
new file mode 100644
index 0000000..38125b3
--- /dev/null
+++ b/hooks/post-tool-use-failure.mjs
@@ -0,0 +1,5 @@
+#!/usr/bin/env node
+import { runRecordingHook } from '../lib/recording-hook.mjs';
+import { noteToolFailure } from '../lib/turn-record.mjs';
+
+runRecordingHook(noteToolFailure);
diff --git a/hooks/post-tool-use.mjs b/hooks/post-tool-use.mjs
new file mode 100644
index 0000000..e51beb3
--- /dev/null
+++ b/hooks/post-tool-use.mjs
@@ -0,0 +1,5 @@
+#!/usr/bin/env node
+import { runRecordingHook } from '../lib/recording-hook.mjs';
+import { noteToolResult } from '../lib/turn-record.mjs';
+
+runRecordingHook(noteToolResult);
diff --git a/hooks/pre-tool-use.mjs b/hooks/pre-tool-use.mjs
new file mode 100644
index 0000000..4cdf386
--- /dev/null
+++ b/hooks/pre-tool-use.mjs
@@ -0,0 +1,6 @@
+#!/usr/bin/env node
+import { projectDirOf } from '../lib/project-dir.mjs';
+import { runRecordingHook } from '../lib/recording-hook.mjs';
+import { noteFileBeforeEdit } from '../lib/turn-record.mjs';
+
+runRecordingHook((sessionId, turnId, input) => noteFileBeforeEdit(sessionId, turnId, projectDirOf(input), input));
diff --git a/hooks/session-end.js b/hooks/session-end.js
deleted file mode 100644
index 332375f..0000000
--- a/hooks/session-end.js
+++ /dev/null
@@ -1,448 +0,0 @@
-// 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 crypto = require('crypto');
-
-const { resolveProjectDir, findMemoryGraph, resolveWorkspaceId, runGit } = require('./_paths');
-const { detectSignals } = require('./_signals');
-
-const STDIN_WATCHDOG_MS = parsePositiveInt(
- process.env.EVOLVER_SESSION_END_STDIN_WATCHDOG_MS,
- 7000
-);
-const GIT_TIMEOUT_MS = 5000;
-const GIT_MAX_BUFFER = 10 * 1024 * 1024; // 10 MB
-const HUB_TIMEOUT_MS = 8000;
-const SESSION_END_DEDUPE_TTL_MS = parsePositiveInt(
- process.env.EVOLVER_SESSION_END_DEDUPE_TTL_MS,
- 6 * 60 * 60 * 1000
-);
-const SESSION_END_PRUNE_MS = Math.max(
- SESSION_END_DEDUPE_TTL_MS,
- 24 * 60 * 60 * 1000
-);
-
-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
- }
-}
-
-function parsePositiveInt(value, fallback) {
- const parsed = Number.parseInt(value || '', 10);
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
-}
-
-/** Run a git subcommand in `cwd`, returning { status, stdout } (stdout = ''). */
-function git(args, cwd) {
- return runGit(args, cwd, {
- timeout: GIT_TIMEOUT_MS,
- maxBuffer: GIT_MAX_BUFFER,
- });
-}
-
-/**
- * Collect the git diff for the session.
- * - statText/body cover the current working tree and staged diff only.
- * - 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';
- if (!isRepo) {
- return {
- isRepo,
- statText: '',
- body: '',
- };
- }
-
- const statParts = [];
- const unstagedStat = git(['diff', '--stat', '--'], projectDir);
- if (unstagedStat.status === 0 && unstagedStat.stdout.trim().length > 0) {
- statParts.push(unstagedStat.stdout);
- }
- const stagedStat = git(['diff', '--cached', '--stat', '--'], projectDir);
- if (stagedStat.status === 0 && stagedStat.stdout.trim().length > 0) {
- statParts.push(stagedStat.stdout);
- }
-
- const bodyParts = [];
- const unstagedBody = git(['diff', '--no-color', '--'], projectDir);
- if (unstagedBody.status === 0 && unstagedBody.stdout.trim().length > 0) {
- bodyParts.push(unstagedBody.stdout);
- }
- const stagedBody = git(['diff', '--cached', '--no-color', '--'], projectDir);
- if (stagedBody.status === 0 && stagedBody.stdout.trim().length > 0) {
- bodyParts.push(stagedBody.stdout);
- }
-
- return {
- isRepo,
- statText: statParts.join('\n'),
- body: bodyParts.join('\n'),
- };
-}
-
-/**
- * Parse "N files changed, A insertions(+), D deletions(-)" from a --stat tail.
- * Missing pieces default to 0.
- */
-function parseStat(statText) {
- function sum(regex) {
- let total = 0;
- for (const match of statText.matchAll(regex)) {
- total += parseInt(match[1], 10);
- }
- return total;
- }
- return {
- files: sum(/(\d+)\s+files?\s+changed/g),
- insertions: sum(/(\d+)\s+insertions?\(\+\)/g),
- deletions: sum(/(\d+)\s+deletions?\(-\)/g),
- };
-}
-
-/** 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'
- );
-}
-
-function hashText(text) {
- return crypto.createHash('sha256').update(String(text || '')).digest('hex');
-}
-
-function parseInput(raw) {
- try {
- const parsed = raw ? JSON.parse(raw) : {};
- return parsed && typeof parsed === 'object' ? parsed : {};
- } catch (_err) {
- return {};
- }
-}
-
-function extractSessionId(input) {
- const props = input && input.properties && typeof input.properties === 'object'
- ? input.properties
- : {};
- const info = props.info && typeof props.info === 'object' ? props.info : {};
- const session = input && input.session && typeof input.session === 'object'
- ? input.session
- : {};
- const candidates = [
- input && input.session_id,
- input && input.sessionId,
- input && input.sessionID,
- session.id,
- session.session_id,
- props.session_id,
- props.sessionId,
- props.sessionID,
- info.id,
- process.env.CURSOR_SESSION_ID,
- ];
- for (const candidate of candidates) {
- if (typeof candidate === 'string' && candidate.trim().length > 0) {
- return candidate.trim();
- }
- }
- return null;
-}
-
-function claimSessionRecord({ projectDir, workspaceId, sessionId, diffHash }) {
- try {
- const base =
- process.env.EVOLVER_SESSION_STATE_DIR ||
- path.join(os.homedir(), '.evolver');
- const stateFile = path.join(base, 'session-end-state.json');
-
- let state = {};
- try {
- state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
- if (!state || typeof state !== 'object' || Array.isArray(state)) {
- state = {};
- }
- } catch (_err) {
- state = {};
- }
-
- const now = Date.now();
- const key = sessionId
- ? `session:${sessionId}`
- : `workspace:${workspaceId || projectDir}:diff:${diffHash}`;
- const previous = state[key];
- const previousTs = typeof previous === 'number'
- ? previous
- : previous && typeof previous.ts === 'number'
- ? previous.ts
- : 0;
- if (previousTs > 0 && now - previousTs < SESSION_END_DEDUPE_TTL_MS) {
- return { claimed: false, key };
- }
-
- state[key] = { ts: now, diff_hash: diffHash };
- for (const existingKey of Object.keys(state)) {
- const value = state[existingKey];
- const ts = typeof value === 'number'
- ? value
- : value && typeof value.ts === 'number'
- ? value.ts
- : 0;
- if (ts <= 0 || now - ts > SESSION_END_PRUNE_MS) {
- delete state[existingKey];
- }
- }
-
- fs.mkdirSync(base, { recursive: true });
- const tmp = `${stateFile}.${process.pid}.tmp`;
- fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
- fs.renameSync(tmp, stateFile);
- return { claimed: true, key };
- } catch (_err) {
- return { claimed: true, key: null };
- }
-}
-
-/** Attempt to POST the outcome to a configured Hub. Never throws. */
-async 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 || typeof fetch !== 'function') {
- return false;
- }
- const url = new URL('/a2a/evolution/record', `${hubUrl.replace(/\/+$/, '')}/`);
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), HUB_TIMEOUT_MS);
- if (typeof timer.unref === 'function') {
- timer.unref();
- }
- try {
- const response = await fetch(url, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `Bearer ${apiKey}`,
- },
- body: JSON.stringify(payload),
- signal: controller.signal,
- });
- return response.ok;
- } finally {
- clearTimeout(timer);
- }
- } 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;
- }
-}
-
-async function finish(projectDir, diff, input) {
- const stats = parseStat(diff.statText);
- const hasChanges =
- diff.statText.trim().length > 0 || diff.body.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'
- : 'git unavailable or not a git workspace';
- appendEvolutionLog(`[Evolution] Session end: nothing recorded (${reason}).`);
- emit({});
- return;
- }
-
- const workspaceId = resolveWorkspaceId(projectDir);
- const sessionId = extractSessionId(input || {});
- const diffHash = hashText(diff.body || diff.statText);
- const claim = claimSessionRecord({
- projectDir,
- workspaceId,
- sessionId,
- diffHash,
- });
- if (!claim.claimed) {
- appendEvolutionLog(
- `[Evolution] Session end: duplicate outcome suppressed (${claim.key}).`
- );
- 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 = await recordToHub({
- gene_id: 'ad_hoc',
- signals,
- status,
- score,
- summary,
- session_id: sessionId,
- workspace_id: workspaceId,
- diff_hash: diffHash,
- 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: workspaceId,
- session_id: sessionId,
- diff_hash: diffHash,
- diff_scope: 'working_tree',
- 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 with a watchdog, then do the work.
-(function run() {
- try {
- let buffer = '';
- let done = false;
-
- const proceed = () => {
- if (done) {
- return;
- }
- done = true;
- try {
- const input = parseInput(buffer);
- const projectDir = resolveProjectDir(input);
- const diff = collectDiff(projectDir);
- Promise.resolve(finish(projectDir, diff, input)).catch(() => {
- emit({});
- });
- } 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.setEncoding('utf8');
- process.stdin.on('data', (chunk) => {
- buffer += chunk;
- });
- process.stdin.on('end', () => {
- clearTimeout(watchdog);
- proceed();
- });
- process.stdin.on('error', () => {
- clearTimeout(watchdog);
- proceed();
- });
- process.stdin.resume();
- } catch (_err) {
- emit({});
- }
-})();
diff --git a/hooks/session-end.mjs b/hooks/session-end.mjs
new file mode 100644
index 0000000..4860627
--- /dev/null
+++ b/hooks/session-end.mjs
@@ -0,0 +1,28 @@
+#!/usr/bin/env node
+import { DISTILL_TASK, DRAIN_TASK, startInBackground } from '../lib/background.mjs';
+import { runHook, sessionIdOf } from '../lib/hook-io.mjs';
+import { forgetSession, takeUnreported } from '../lib/injected-assets.mjs';
+import { projectDirOf } from '../lib/project-dir.mjs';
+import { queueReports } from '../lib/report-queue.mjs';
+import { closeCandidate } from '../lib/session-distill.mjs';
+import { endSession, settledSession } from '../lib/session-state.mjs';
+import { abortedReports, endOpenTurn } from '../lib/turn-end.mjs';
+
+const STOP_WAIT_MS = 2_000;
+
+// Cursor waits for sessionEnd only while a window closes, and kills it at its timeout, so it only settles state
+// on disk; the closed candidate is distilled by the detached worker it starts, or claimed by the next session
+// start if that worker fails. A stop already settling its turn is let finish first, so its candidate is the one
+// closed here; a turn still running is cut off by the close and ends aborted.
+runHook(async (input) => {
+ const sessionId = sessionIdOf(input);
+ if (!sessionId) return {};
+ const session = await settledSession(sessionId, STOP_WAIT_MS);
+ if (!session.closed) endOpenTurn({ sessionId, reasonKind: 'aborted', projectDir: projectDirOf(input) });
+ queueReports(abortedReports(takeUnreported(sessionId, Infinity)));
+ closeCandidate(sessionId);
+ endSession(sessionId);
+ forgetSession(sessionId);
+ startInBackground([DRAIN_TASK, DISTILL_TASK]);
+ return {};
+});
diff --git a/hooks/session-start.js b/hooks/session-start.js
deleted file mode 100644
index 97b2f26..0000000
--- a/hooks/session-start.js
+++ /dev/null
@@ -1,348 +0,0 @@
-// 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,
- gitUsability,
- 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 CLAIM_TTL_MS = 12 * 60 * 60 * 1000; // throttle the pending-claim nudge
-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). Open a git project to ' +
- 'enable recall and recording.';
-
-const NOGIT_BINARY_NOTICE =
- '[Evolver] A git repository is present but git itself is not usable, so ' +
- 'evolution memory cannot record diffs. On macOS this usually means Xcode ' +
- 'Command Line Tools are missing: run xcode-select --install, or install ' +
- 'Git with Homebrew (brew install git) so /opt/homebrew/bin/git exists. ' +
- 'Do not use Apple\'s /usr/bin/git stub — it pops a developer-tools dialog ' +
- 'and Cursor reports "Error loading plugin".';
-
-// 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
- }
-}
-
-/**
- * Read a pending claim URL left by the engine. When the engine registers a
- * fresh node that hasn't been claimed on evomap.ai yet, it writes the claim
- * link to `~/.evomap/claim_url`; it clears that file once the node is claimed.
- * Returns the trimmed url if it looks like an http(s) link, else null.
- * Fails closed (null) on any error — the nudge is optional.
- */
-function readPendingClaimUrl() {
- try {
- const file = path.join(os.homedir(), '.evomap', 'claim_url');
- const url = fs.readFileSync(file, 'utf8').trim();
- if (/^https?:\/\//.test(url)) {
- return url;
- }
- return null;
- } catch (_err) {
- return null;
- }
-}
-
-/**
- * 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 / unusable-git notice (throttled per directory).
- try {
- const git = gitUsability(currentDir);
- if (!git.hasRepo) {
- if (!throttled(`nongit:${currentDir}`, NONGIT_TTL_MS)) {
- parts.push(NONGIT_NOTICE);
- }
- } else if (!git.gitBinary) {
- if (!throttled(`nogitbin:${currentDir}`, NONGIT_TTL_MS)) {
- parts.push(NOGIT_BINARY_NOTICE);
- }
- }
- } catch (_err) {
- // ignore — notice is optional
- }
-
- // 1b. Pending-claim nudge (throttled per claim url).
- try {
- const claimUrl = readPendingClaimUrl();
- if (claimUrl && !throttled(`claim:${claimUrl}`, CLAIM_TTL_MS)) {
- parts.push(
- '[Evolver] Your local node is not connected to the EvoMap network ' +
- 'yet. To finish connecting, open ' +
- claimUrl +
- ' while signed in to evomap.ai — that is the only step, with no id ' +
- 'or secret to enter. Local evolution memory already works without ' +
- 'this; connecting only adds the network gene/capsule tools.'
- );
- }
- } catch (_err) {
- // ignore — nudge 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/hooks/session-start.mjs b/hooks/session-start.mjs
new file mode 100644
index 0000000..0a10dcd
--- /dev/null
+++ b/hooks/session-start.mjs
@@ -0,0 +1,44 @@
+#!/usr/bin/env node
+import { DISTILL_TASK, DRAIN_TASK, ENGINE_TASK, startInBackground } from '../lib/background.mjs';
+import { engineNoticeOf } from '../lib/engine-notice.mjs';
+import { isSetupDue } from '../lib/engine-setup.mjs';
+import { isAnnounced, markAnnounced, readEngineState } from '../lib/engine-state.mjs';
+import { meetsMinimum } from '../lib/engine-version.mjs';
+import { runHook, sessionIdOf } from '../lib/hook-io.mjs';
+import { queueOrphanedUnreported } from '../lib/injected-assets.mjs';
+import { projectDirOf } from '../lib/project-dir.mjs';
+import { runningProxy } from '../lib/proxy.mjs';
+import { queueReports } from '../lib/report-queue.mjs';
+import { retentionReportsOf, takeDueRetentions } from '../lib/retention-pending.mjs';
+import { abortedReports } from '../lib/turn-end.mjs';
+
+function queueLeftReports(sessionId, projectDir) {
+ const { due, commit } = takeDueRetentions({ sessionId, projectDir });
+ queueReports(due.flatMap(retentionReportsOf));
+ commit();
+ queueOrphanedUnreported((orphaned) => queueReports(abortedReports(orphaned)));
+}
+
+// Setting Evolver up takes minutes, so it runs in the background and only its outcome is told, once.
+function engineNoticeOnce(outcome) {
+ if (!outcome || isAnnounced(outcome)) return null;
+ markAnnounced(outcome);
+ return engineNoticeOf(outcome);
+}
+
+async function isEngineSetupDue(outcome) {
+ const running = await runningProxy();
+ return !(running && meetsMinimum(running.version)) && isSetupDue(outcome, Date.now());
+}
+
+async function sessionStart(input) {
+ const sessionId = sessionIdOf(input);
+ if (!sessionId) return {};
+ queueLeftReports(sessionId, projectDirOf(input));
+ const outcome = readEngineState();
+ startInBackground([...(await isEngineSetupDue(outcome) ? [ENGINE_TASK] : []), DRAIN_TASK, DISTILL_TASK]);
+ const notice = engineNoticeOnce(outcome);
+ return notice ? { additional_context: notice } : {};
+}
+
+runHook(sessionStart);
diff --git a/hooks/signal-detect.js b/hooks/signal-detect.js
deleted file mode 100644
index bf96540..0000000
--- a/hooks/signal-detect.js
+++ /dev/null
@@ -1,134 +0,0 @@
-// 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/hooks/stop.mjs b/hooks/stop.mjs
new file mode 100644
index 0000000..c685930
--- /dev/null
+++ b/hooks/stop.mjs
@@ -0,0 +1,23 @@
+#!/usr/bin/env node
+import { DRAIN_TASK, startInBackground } from '../lib/background.mjs';
+import { generationIdOf, runHook, sessionIdOf } from '../lib/hook-io.mjs';
+import { hostProcess } from '../lib/process-owner.mjs';
+import { projectDirOf } from '../lib/project-dir.mjs';
+import { modelOf } from '../lib/session-distill.mjs';
+import { endOpenTurn } from '../lib/turn-end.mjs';
+
+const REASON_KINDS = { completed: 'completed', aborted: 'aborted', error: 'error' };
+
+runHook((input) => {
+ const sessionId = sessionIdOf(input);
+ const ended = sessionId && endOpenTurn({
+ sessionId,
+ turnId: generationIdOf(input),
+ reasonKind: REASON_KINDS[input.status] ?? 'error',
+ model: modelOf(input),
+ projectDir: projectDirOf(input),
+ ownerPid: () => hostProcess()?.pid ?? null,
+ });
+ if (ended?.queued) startInBackground([DRAIN_TASK]);
+ return {};
+});
diff --git a/hooks/user-prompt-submit.mjs b/hooks/user-prompt-submit.mjs
new file mode 100644
index 0000000..17f5611
--- /dev/null
+++ b/hooks/user-prompt-submit.mjs
@@ -0,0 +1,102 @@
+#!/usr/bin/env node
+import { DRAIN_TASK, startInBackground } from '../lib/background.mjs';
+import { EVOLVE_COMMAND, typedCommandOf } from '../lib/commands.mjs';
+import { feedbackLabelOf } from '../lib/feedback-label.mjs';
+import { generationIdOf, runHook, sessionIdOf } from '../lib/hook-io.mjs';
+import { injectedIds, rememberInjected } from '../lib/injected-assets.mjs';
+import { answerOfCommand } from '../lib/plugin-commands.mjs';
+import { hubGene, promptTextOf } from '../lib/prime.mjs';
+import { hostProcess } from '../lib/process-owner.mjs';
+import { projectDirOf } from '../lib/project-dir.mjs';
+import { createProxyClient } from '../lib/proxy.mjs';
+import { recordRecall } from '../lib/recall-log.mjs';
+import { queueReports } from '../lib/report-queue.mjs';
+import { correctionReportOf } from '../lib/reuse.mjs';
+import { dropCandidate } from '../lib/session-distill.mjs';
+import { readSession, settledSession, updateSession } from '../lib/session-state.mjs';
+import { endOpenTurn } from '../lib/turn-end.mjs';
+import { forgetTurnRecord } from '../lib/turn-record.mjs';
+
+const RECALL_TIMEOUT_MS = 6_000;
+const STOP_WAIT_MS = 3_000;
+
+// Cursor fires the next prompt without waiting for the previous turn's stop (a prompt typed mid-turn aborts that
+// turn first), so the previous turn gets a moment to close and settle: its reports and candidate land before this
+// prompt labels or drops them. One that stays open never had a stop (Cursor itself went down) and is closed as an
+// error.
+async function closeLeftTurn(sessionId, input) {
+ const session = await settledSession(sessionId, STOP_WAIT_MS);
+ if (session.closed) return { labelable: session.settling ? [] : session.labelable, queued: false };
+ const ended = endOpenTurn({ sessionId, reasonKind: 'error', projectDir: projectDirOf(input) });
+ return { labelable: ended?.reuses ?? [], queued: Boolean(ended?.queued) };
+}
+
+// The next prompt is the only verdict on a turn available without asking anyone: when it reads as a
+// correction, the reuse reports are revised and the turn is no longer worth distilling.
+function reportFollowUp(sessionId, labelable, prompt) {
+ if (!prompt) return false;
+ const label = feedbackLabelOf(prompt);
+ if (label === 'correction') dropCandidate(sessionId);
+ const reports = labelable.map((reuse) => correctionReportOf(reuse, label)).filter((report) => report !== null);
+ queueReports(reports.map((report) => ({ report, sessionId })));
+ return reports.length > 0;
+}
+
+function openTurn(sessionId, input, prompt, { keepLabelable }) {
+ const turnId = generationIdOf(input);
+ forgetTurnRecord(sessionId, turnId);
+ return updateSession(sessionId, (session) => ({
+ ...session, turn: session.turn + 1, turnId, closed: false, settling: false, prompt,
+ labelable: keepLabelable ? session.labelable : [], keepsLabelable: keepLabelable,
+ }));
+}
+
+async function recalledStrategy(sessionId, turn, prompt) {
+ const match = await hubGene(createProxyClient({ timeoutMs: RECALL_TIMEOUT_MS }), promptTextOf(prompt), {
+ listedIds: injectedIds(sessionId),
+ onOutcome: recordRecall,
+ });
+ const [assetId] = match.ids;
+ if (!assetId) return '';
+ rememberInjected(sessionId, assetId, { turn, strategyText: match.text, pid: hostProcess()?.pid ?? null });
+ return match.text;
+}
+
+// A command that fails still answers in the chat: passed on, its prompt would tell the model the hooks are off.
+async function commandAnswer(typed, sessionId) {
+ try {
+ return await answerOfCommand(typed, sessionId);
+ } catch (error) {
+ return `${typed.name} failed: ${String(error?.message ?? error)}`;
+ }
+}
+
+// Only the agent chat (editor or CLI) names its composer_mode; an inline edit sends none and drops added context.
+function isAgentChat(input) {
+ return typeof input.composer_mode === 'string';
+}
+
+// `/evolver-evolve` is a request, not a verdict on the previous turn, so that turn's reuses wait for the next prompt.
+async function promptTurn(sessionId, input, prompt, typed) {
+ const isEvolve = typed?.name === EVOLVE_COMMAND;
+ const closed = await closeLeftTurn(sessionId, input);
+ const followedUp = !isEvolve && reportFollowUp(sessionId, closed.labelable, prompt);
+ if (closed.queued || followedUp) startInBackground([DRAIN_TASK]);
+ const { turn } = openTurn(sessionId, input, prompt, { keepLabelable: isEvolve });
+ const context = await recalledStrategy(sessionId, turn, prompt);
+ return context ? { continue: true, additional_context: context } : {};
+}
+
+async function beforeSubmitPrompt(input) {
+ const sessionId = sessionIdOf(input);
+ const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
+ if (!sessionId || !isAgentChat(input)) return {};
+ const typed = typedCommandOf(prompt);
+ const answer = await commandAnswer(typed, sessionId);
+ if (answer !== null) return { continue: false, user_message: answer };
+ const generationId = generationIdOf(input);
+ if (generationId && generationId === readSession(sessionId).turnId) return {};
+ return promptTurn(sessionId, input, prompt, typed);
+}
+
+runHook(beforeSubmitPrompt);
diff --git a/lib/applied.mjs b/lib/applied.mjs
new file mode 100644
index 0000000..2268a52
--- /dev/null
+++ b/lib/applied.mjs
@@ -0,0 +1,67 @@
+const STEP_PREFIX = /^\s*\d+\.\s+/;
+const BACKTICKED = /`([^`\n]{2,120})`/g;
+const PATH_LIKE = /(?:^|[\s("'])((?:\.{0,2}\/)?[\w@.-]+(?:\/[\w@.-]+)+|[\w-]+\.(?:js|mjs|cjs|ts|tsx|jsx|py|go|rs|java|kt|rb|php|json|ya?ml|toml|md|sh|sql|css|html|vue|svelte))(?=$|[\s)"',.:;])/g;
+const COMMAND_LIKE = /\b((?:npm|npx|pnpm|yarn|bun|node|git|go|cargo|pytest|python3?|pip|make|docker|kubectl|tsc|eslint|jest|vitest|cursor-agent|gh)\s+[\w:@./=-]+)/g;
+const WORD = /[\p{L}\p{N}_]{4,}/gu;
+const STOPWORDS = new Set([
+ 'that', 'this', 'with', 'from', 'into', 'then', 'when', 'each', 'only', 'before', 'after', 'step', 'steps', 'make', 'sure',
+ 'should', 'must', 'never', 'always', 'use', 'using', 'used', 'have', 'has', 'were', 'will', 'your', 'their', 'there', 'these',
+ 'those', 'what', 'which', 'while', 'until', 'other', 'every', 'more', 'than', 'also', 'apply', 'where', 'fits', 'report',
+ 'outcome', 'evolver_asset_reuse_result', 'safety', 'boundary', 'optional', 'otherwise', 'first', 'next', 'last', 'same',
+]);
+
+const USED_RATIO = 0.5;
+export const UNKNOWN = 'unknown';
+const TERM_COVERAGE = 0.5;
+const MIN_STEP_TERMS = 3;
+
+export function strategyStepsOf(injectedText) {
+ return String(injectedText ?? '')
+ .split('\n')
+ .filter((line) => STEP_PREFIX.test(line))
+ .map((line) => line.replace(STEP_PREFIX, '').trim());
+}
+
+function matchesOf(text, pattern) {
+ return [...text.matchAll(pattern)].map((match) => match[1].trim().toLowerCase());
+}
+
+export function concreteFeaturesOf(step) {
+ const features = [...matchesOf(step, BACKTICKED), ...matchesOf(step, PATH_LIKE), ...matchesOf(step, COMMAND_LIKE)];
+ return [...new Set(features.map((feature) => feature.replace(/[.,:;]+$/, '')).filter((feature) => feature.length >= 3))];
+}
+
+function termsOf(text) {
+ return [...new Set((String(text).toLowerCase().match(WORD) ?? []).filter((term) => !STOPWORDS.has(term)))];
+}
+
+function turnHaystack({ commands = [], paths = [], edits = [], answer = '' }) {
+ return [...commands, ...paths, ...edits, answer].join('\n').toLowerCase();
+}
+
+function termsWereFollowed(step, haystack) {
+ const terms = termsOf(step);
+ if (terms.length < MIN_STEP_TERMS) return false;
+ return terms.filter((term) => haystack.includes(term)).length / terms.length >= TERM_COVERAGE;
+}
+
+function stepWasFollowed(step, haystack) {
+ return concreteFeaturesOf(step).some((feature) => haystack.includes(feature)) || termsWereFollowed(step, haystack);
+}
+
+function leftNothingToCompare({ commands = [], paths = [], edits = [] }) {
+ return commands.length + paths.length + edits.length === 0;
+}
+
+function verdictOf(turn, steps, hits) {
+ if (hits > 0) return hits / steps >= USED_RATIO ? 'used' : 'partial';
+ return steps === 0 || leftNothingToCompare(turn) ? UNKNOWN : 'unused';
+}
+
+export function appliedVerdictOf({ strategyText, ...turn }) {
+ const steps = strategyStepsOf(strategyText);
+ const haystack = turnHaystack(turn);
+ const hits = steps.filter((step) => stepWasFollowed(step, haystack)).length;
+ const ratio = steps.length === 0 ? 0 : hits / steps.length;
+ return { verdict: verdictOf(turn, steps.length, hits), hits, steps: steps.length, ratio: Math.round(ratio * 1000) / 1000 };
+}
diff --git a/lib/background.mjs b/lib/background.mjs
new file mode 100644
index 0000000..abd23bf
--- /dev/null
+++ b/lib/background.mjs
@@ -0,0 +1,17 @@
+import { spawn } from 'node:child_process';
+import os from 'node:os';
+import { fileURLToPath } from 'node:url';
+
+const WORKER = fileURLToPath(new URL('../scripts/background-worker.mjs', import.meta.url));
+
+export const ENGINE_TASK = 'engine';
+export const DRAIN_TASK = 'drain';
+export const DISTILL_TASK = 'distill';
+
+// Cursor kills a hook at its timeout and waits for sessionEnd only while a window closes, so slow work
+// (installing Evolver, posting reports, distilling) runs in a detached child in a process group of its own.
+export function startInBackground(tasks) {
+ if (tasks.length === 0) return;
+ const child = spawn(process.execPath, [WORKER, ...new Set(tasks)], { detached: true, stdio: 'ignore', windowsHide: true, cwd: os.homedir() });
+ child.unref();
+}
diff --git a/lib/checks.mjs b/lib/checks.mjs
new file mode 100644
index 0000000..bc3a3f4
--- /dev/null
+++ b/lib/checks.mjs
@@ -0,0 +1,27 @@
+const CHECK_KINDS = [
+ ['typecheck', /\btsc\b|\btype-?check\b|\bmypy\b|\bcargo\s+check\b|\bgo\s+vet\b/],
+ ['lint', /\blint\b|\beslint\b|\bruff\b|\bclippy\b|\bvalidate\b/],
+ ['test', /\btest\b|\bjest\b|\bvitest\b|\bmocha\b|\bpytest\b|\brspec\b|\bphpunit\b/],
+];
+
+export function checkKindOf(command) {
+ return CHECK_KINDS.find(([, pattern]) => pattern.test(command))?.[0] ?? 'build';
+}
+
+const OUTPUT_PIPE = /\s*(?&?\s*(?:\d|\S+)/g;
+
+export function checkIdentityOf(command) {
+ return String(command).replace(OUTPUT_PIPE, '').replace(STREAM_REDIRECT, '').replace(/\s+/g, ' ').trim();
+}
+
+function failedEarlierThenPassed(checks, last) {
+ const identity = checkIdentityOf(last.command);
+ return !last.failed && checks.slice(0, -1).some(({ command, failed }) => failed && checkIdentityOf(command) === identity);
+}
+
+export function checkEvidenceOf(checks) {
+ const last = checks?.at(-1);
+ if (!last) return undefined;
+ return { kind: checkKindOf(last.command), passed: !last.failed, fail_then_pass: failedEarlierThenPassed(checks, last) };
+}
diff --git a/lib/commands.mjs b/lib/commands.mjs
new file mode 100644
index 0000000..b9970a9
--- /dev/null
+++ b/lib/commands.mjs
@@ -0,0 +1,182 @@
+import { execFile } from 'node:child_process';
+
+import { MIN_EVOLVER_VERSION } from './engine-version.mjs';
+
+// Cursor commands are prompts for the model, so these run in the plugin instead: the beforeSubmitPrompt hook
+// answers a typed `/evolver-…` line itself and stops the prompt before any model sees it. Only
+// `/evolver-evolve` stays a prompt, because reflecting on the current work is the agent's job.
+
+export const NAME_PREFIX = 'evolver-';
+const OUTPUT_MAX_CHARS = 4_000;
+const CLI_TIMEOUT_MS = 10 * 60 * 1000;
+const KILL_GRACE_MS = 5_000;
+// The fallback runs whatever npm resolves, so it is held to the range this plugin is built against.
+const CLI_PACKAGE = `@evomap/evolver@^${MIN_EVOLVER_VERSION}`;
+export const EVOLVE_COMMAND = `${NAME_PREFIX}evolve`;
+const TYPED_COMMAND = /^\/((?:[\w-]+:)?evolver-[\w-]+)(?:[ \t]+([\s\S]*))?$/;
+
+const success = (text) => ({ kind: 'success', text });
+const failure = (text) => ({ kind: 'error', text });
+
+function shown(value) {
+ const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
+ return text.length > OUTPUT_MAX_CHARS ? `${text.slice(0, OUTPUT_MAX_CHARS)}\n…` : text;
+}
+
+const IS_WINDOWS = process.platform === 'win32';
+
+// The CLI starts the Proxy and workers of its own, so a timed-out run takes down the whole tree it leads rather
+// than only the process this plugin started: its process group on POSIX, and through taskkill on Windows, where
+// the child is the cmd.exe that runs the npm shim.
+function taskkill(pid) {
+ execFile('taskkill', ['/pid', String(pid), '/T', '/F'], { windowsHide: true }, () => {});
+}
+
+function signalGroup(pid, name) {
+ try {
+ process.kill(-pid, name);
+ } catch {
+ }
+}
+
+export function endTree(pid, { platform = process.platform, killWindowsTree = taskkill, signal = signalGroup } = {}) {
+ if (platform === 'win32') return killWindowsTree(pid);
+ signal(pid, 'SIGTERM');
+ setTimeout(() => signal(pid, 'SIGKILL'), KILL_GRACE_MS).unref?.();
+}
+
+// `evolver` and `npx` are npm .cmd shims on Windows, which only run through cmd.exe. cmd.exe reads the line
+// once and the shim re-reads its arguments, so each argument is quoted for the C runtime and its cmd
+// metacharacters are caret-escaped twice, so that none of them runs as a command.
+const CMD_METACHARACTERS = /([()\][%!^"`<>&|;, *?])/g;
+
+export function cmdArgument(arg) {
+ const quoted = `"${String(arg).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
+ return quoted.replace(CMD_METACHARACTERS, '^$1').replace(CMD_METACHARACTERS, '^$1');
+}
+
+function execute(file, args) {
+ return new Promise((resolve) => {
+ let timer = null;
+ const options = { maxBuffer: 16 * 1024 * 1024, detached: !IS_WINDOWS, shell: IS_WINDOWS, windowsHide: true };
+ const child = execFile(file, IS_WINDOWS ? args.map(cmdArgument) : args, options, (error, stdout, stderr) => {
+ clearTimeout(timer);
+ resolve({ error, output: `${stdout ?? ''}${stderr ? `\n${stderr}` : ''}`.trim() });
+ });
+ if (!child.pid) return;
+ timer = setTimeout(() => endTree(child.pid), CLI_TIMEOUT_MS);
+ timer.unref?.();
+ });
+}
+
+function isOnWindowsPath(name) {
+ return new Promise((resolve) => execFile('where', [name], { windowsHide: true }, (error) => resolve(!error)));
+}
+
+// Through cmd.exe a missing command is only a non-zero exit with a localized message, so on Windows a failed
+// run asks `where` whether `evolver` exists at all.
+export async function isMissingExecutable(error, name, { platform = process.platform, isOnPath = isOnWindowsPath } = {}) {
+ if (!error) return false;
+ if (error.code === 'ENOENT') return true;
+ return platform === 'win32' && !(await isOnPath(name));
+}
+
+// The installed `evolver` when there is one, else the published package through npx.
+export async function runEvolverCli(args, { run = execute, isMissing = isMissingExecutable } = {}) {
+ let { error, output } = await run('evolver', args);
+ if (await isMissing(error, 'evolver')) ({ error, output } = await run('npx', ['-y', CLI_PACKAGE, ...args]));
+ return error ? failure(shown(output || String(error.message))) : success(shown(output || 'done'));
+}
+
+function cliCommand(subcommand, description, hint, runCli) {
+ return {
+ name: `${NAME_PREFIX}${subcommand}`,
+ description,
+ input: { hint },
+ handler: ({ args }) => runCli([subcommand, ...args]),
+ };
+}
+
+function distillMessage(outcome) {
+ if (outcome.status === 'nothing') return failure('Nothing to distill yet: no turn of this session finished verified.');
+ if (outcome.status === 'running') return failure('A distillation of this session is already running.');
+ return outcome.status === 'sent' ? success(shown(outcome.data ?? 'distilled')) : failure(`Distillation ${outcome.status}: ${outcome.error ?? ''}`.trim());
+}
+
+/**
+ * Quotes group words the way a shell would (`--summary="two words"` stays one argument) and a backslash
+ * escapes the character after it, so a quote can be part of a value; nothing else is expanded.
+ */
+export function argumentsOf(rawInput) {
+ const args = [];
+ let current = '';
+ let quote = null;
+ let started = false;
+ let escaped = false;
+ for (const char of rawInput) {
+ if (escaped) {
+ current += char;
+ escaped = false;
+ } else if (char === '\\' && quote !== "'") {
+ escaped = true;
+ started = true;
+ } else if (quote) {
+ if (char === quote) quote = null;
+ else current += char;
+ } else if (char === '"' || char === "'") {
+ quote = char;
+ started = true;
+ } else if (/\s/.test(char)) {
+ if (started) args.push(current);
+ current = '';
+ started = false;
+ } else {
+ current += char;
+ started = true;
+ }
+ }
+ if (started) args.push(current);
+ return args;
+}
+
+/** The `/evolver-…` command a prompt is, with its arguments, or null; a plugin namespace before it is dropped. */
+export function typedCommandOf(prompt) {
+ const match = TYPED_COMMAND.exec(String(prompt ?? '').trim());
+ if (!match) return null;
+ return { name: match[1].replace(/^[\w-]+:/, ''), args: argumentsOf(match[2] ?? '') };
+}
+
+/**
+ * `status` and `search` answer from the Proxy, `distill` distills this chat's latest verified turn now, and
+ * the engine commands run the `evolver` executable.
+ */
+export function evolverCommands({ status, search, distillNow, runCli = runEvolverCli }) {
+ return [
+ {
+ name: `${NAME_PREFIX}status`,
+ description: 'Show Evolver health: the Proxy, engine setup, recent recalls, reuse reports and distillation candidates.',
+ handler: async () => success(await status()),
+ },
+ {
+ name: `${NAME_PREFIX}search`,
+ description: 'Search the EvoMap network for reusable assets.',
+ input: { hint: '' },
+ handler: async ({ args }) => {
+ const text = args.join(' ').trim();
+ if (!text) return failure(`Usage: ${NAME_PREFIX}search `);
+ const result = await search(text);
+ return result.ok ? success(shown(result.data)) : failure(String(result.error));
+ },
+ },
+ {
+ name: `${NAME_PREFIX}distill`,
+ description: "Distill this chat's latest verified turn into a reusable Gene now, instead of at session end.",
+ input: { hint: '[focus]' },
+ handler: async ({ args }) => distillMessage(await distillNow(args.join(' '))),
+ },
+ cliCommand('sync', 'Sync assets between the local store and the EvoMap Hub (evolver sync).', '[--scope=…] [--type=…] [--dry-run]', runCli),
+ cliCommand('review', "Review Evolver's pending evolved changes, then approve or reject them (evolver review).", '[--approve | --reject]', runCli),
+ cliCommand('run', 'Run one Evolver self-evolution cycle on this repository (evolver run).', '[--loop] [--dry-run] [--strategy=…]', runCli),
+ cliCommand('solidify', 'Solidify the working changes into a durable Gene or Capsule (evolver solidify).', '[--dry-run] [--intent=…] [--summary=…]', runCli),
+ ];
+}
diff --git a/lib/cursor-agent.mjs b/lib/cursor-agent.mjs
new file mode 100644
index 0000000..ffc1b6e
--- /dev/null
+++ b/lib/cursor-agent.mjs
@@ -0,0 +1,107 @@
+import { spawn } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+import { HEADLESS_MARKER } from './hook-io.mjs';
+
+const MODEL_ID = /^[\w.:@/-]+$/;
+const UNNAMED_MODELS = new Set(['default', 'auto', 'unknown']);
+const ERROR_MAX_CHARS = 300;
+const KILL_GRACE_MS = 5_000;
+const CLI_NAME = 'cursor-agent';
+const SHORT_NAME = 'agent';
+// On Windows only an .exe is spawned without a shell; through cmd.exe, which a .cmd shim needs, the prompt
+// argument would overrun its 8191-character command line.
+const executableName = (name, platform) => (platform === 'win32' ? `${name}.exe` : name);
+
+function pathDirs(env) {
+ return String(env.PATH ?? '').split(path.delimiter).filter(Boolean);
+}
+
+function realPathOf(file) {
+ try {
+ return fs.realpathSync(file);
+ } catch {
+ return null;
+ }
+}
+
+// `agent` is the name Cursor's installer gives its CLI, but a generic one, so it only counts when it resolves
+// into the CLI's own install.
+function isCursorAgent(file) {
+ return realPathOf(file)?.includes(CLI_NAME) ?? false;
+}
+
+/** The Cursor agent CLI to run headless: `cursor-agent`, else an `agent` that is it; null when there is none. */
+export function cursorAgentCli({ env = process.env, home = os.homedir(), platform = process.platform, exists = fs.existsSync, isCli = isCursorAgent } = {}) {
+ const dirs = [...pathDirs(env), path.join(home, '.local', 'bin')];
+ const inDirs = (name) => dirs.map((dir) => path.join(dir, executableName(name, platform)));
+ return inDirs(CLI_NAME).find((file) => exists(file)) ?? inDirs(SHORT_NAME).find((file) => exists(file) && isCli(file)) ?? null;
+}
+
+/**
+ * One answer, not an agent run: print mode in ask mode (read-only, and without --force nothing is applied),
+ * an empty workspace so no project rules, hooks or files come along, and the prompt as the argument the docs
+ * name. The marker keeps this plugin's own hooks, which the CLI loads too, from recalling or distilling here.
+ */
+export function cursorAgentArguments(model, workspace, prompt) {
+ const named = MODEL_ID.test(model ?? '') && !UNNAMED_MODELS.has(model) ? ['--model', model] : [];
+ return ['-p', '--output-format', 'json', '--mode', 'ask', '--trust', '--workspace', workspace, ...named, prompt];
+}
+
+function parsedJson(text) {
+ try {
+ return JSON.parse(text);
+ } catch {
+ return null;
+ }
+}
+
+/** The answer of one `--output-format json` run, which prints a single `result` object and no token usage. */
+export function completionOf({ code, stdout, stderr }) {
+ const result = parsedJson(String(stdout ?? '').trim());
+ if (code !== 0 || !result || result.is_error || typeof result.result !== 'string') {
+ const detail = `${stderr ?? ''}${stdout ?? ''}`.trim().slice(-ERROR_MAX_CHARS) || 'empty output';
+ throw new Error(`cursor-agent exited ${code}: ${detail}`);
+ }
+ return { answer: result.result, usage: null };
+}
+
+function runCli(file, args, signal) {
+ return new Promise((resolve, reject) => {
+ const env = { ...process.env, [HEADLESS_MARKER]: '1' };
+ const child = spawn(file, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, cwd: os.homedir(), env });
+ let stdout = '';
+ let stderr = '';
+ const stop = () => {
+ child.kill('SIGTERM');
+ setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_MS).unref();
+ };
+ signal?.addEventListener('abort', stop, { once: true });
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
+ child.on('error', reject);
+ child.on('close', (code) => {
+ signal?.removeEventListener('abort', stop);
+ if (signal?.aborted) reject(Object.assign(new Error('cancelled'), { code: 'ABORT_ERR' }));
+ else resolve({ code, stdout, stderr });
+ });
+ });
+}
+
+/**
+ * `runModel(model, prompt, signal)` over the headless Cursor agent CLI, in a scratch workspace that is removed
+ * afterwards. Rejects with code ENOENT when there is no CLI to run; hooks have no model API of their own.
+ */
+export function cursorModelRunner({ cli = cursorAgentCli(), run = runCli } = {}) {
+ return async (model, prompt, signal) => {
+ if (!cli) throw Object.assign(new Error('no cursor-agent executable on PATH or in ~/.local/bin'), { code: 'ENOENT' });
+ const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-distill-'));
+ try {
+ return completionOf(await run(cli, cursorAgentArguments(model, workspace, prompt), signal));
+ } finally {
+ fs.rmSync(workspace, { recursive: true, force: true });
+ }
+ };
+}
diff --git a/lib/dissatisfaction.mjs b/lib/dissatisfaction.mjs
new file mode 100644
index 0000000..fb56409
--- /dev/null
+++ b/lib/dissatisfaction.mjs
@@ -0,0 +1,31 @@
+// A correction is only worth sending when the person plainly says the last
+// answer did not hold. Anything softer — a new question, a follow-up feature,
+// a report of some other error — must not revise a verdict, because the Hub
+// counts every report and there is no way to take one back.
+//
+// Questions are the trap this has to survive: `行不行`, `对不对`, `不行吗` and
+// a closing `不对吧` ask for an opinion and contain `不行` and `不对` verbatim.
+const ASKS_FOR_OPINION = /\s*(?:[吗嘛么]|吧?\s*[??]|吧\s*[。.!!~~]*\s*$)/;
+const CORRECTION_PATTERNS = [
+ new RegExp(`(? pattern.test(text));
+}
diff --git a/lib/distiller.mjs b/lib/distiller.mjs
new file mode 100644
index 0000000..f13c269
--- /dev/null
+++ b/lib/distiller.mjs
@@ -0,0 +1,43 @@
+import {
+ claimLeftoverCandidates, claimSessionCandidate, distillCandidate, dueCandidates, isCurrentCandidate, settleCandidate,
+} from './session-distill.mjs';
+
+export const DISTILL_TIMEOUT_MS = 120_000;
+const CURRENT_POLL_MS = 1_000;
+
+// The correction that drops a candidate arrives in another hook process, so a running distillation watches
+// the candidate file and stops itself once its candidate is no longer the one on disk.
+function cancelWhenReplaced(sessionId, candidate, pollMs) {
+ const controller = new AbortController();
+ const timer = setInterval(() => {
+ if (!isCurrentCandidate(sessionId, candidate)) controller.abort();
+ }, pollMs);
+ timer.unref();
+ return { signal: controller.signal, stop: () => clearInterval(timer) };
+}
+
+export async function distillDue({ sessionId, candidate }, { proxyFetch, runModel, focus = '', timeoutMs = DISTILL_TIMEOUT_MS, pollMs = CURRENT_POLL_MS }) {
+ const cancel = cancelWhenReplaced(sessionId, candidate, pollMs);
+ try {
+ const isCurrent = () => isCurrentCandidate(sessionId, candidate);
+ const outcome = await distillCandidate({ proxyFetch, runModel, candidate, focus, timeoutMs, cancel: cancel.signal, isCurrent });
+ settleCandidate(sessionId, candidate, { sent: outcome.status === 'sent' });
+ return outcome;
+ } finally {
+ cancel.stop();
+ }
+}
+
+/** Every leftover candidate this process could claim, one at a time. */
+export async function distillLeftovers(deps) {
+ const outcomes = [];
+ for (const due of claimLeftoverCandidates()) outcomes.push({ sessionId: due.sessionId, ...(await distillDue(due, deps)) });
+ return outcomes;
+}
+
+/** One session's candidate now; `running` when another live process is already distilling it. */
+export async function distillSession(sessionId, deps) {
+ const [claimed] = claimSessionCandidate(sessionId);
+ if (claimed) return distillDue(claimed, deps);
+ return dueCandidates({ sessionId }).length > 0 ? { status: 'running' } : { status: 'nothing' };
+}
diff --git a/lib/engine-job.mjs b/lib/engine-job.mjs
new file mode 100644
index 0000000..64257b9
--- /dev/null
+++ b/lib/engine-job.mjs
@@ -0,0 +1,15 @@
+import { isSetupDue, prepareEngine, setupErrorOutcome } from './engine-setup.mjs';
+import { readEngineState, writeEngineState } from './engine-state.mjs';
+import { withLock } from './state-file.mjs';
+
+export function setUpEngine(runtime) {
+ return withLock('cursor-engine', async () => {
+ const previous = readEngineState();
+ if (!isSetupDue(previous, runtime.now())) return;
+ try {
+ writeEngineState(await prepareEngine(runtime, previous));
+ } catch (error) {
+ writeEngineState(setupErrorOutcome(error, previous, runtime.now()));
+ }
+ });
+}
diff --git a/lib/engine-notice.mjs b/lib/engine-notice.mjs
new file mode 100644
index 0000000..d96a7fa
--- /dev/null
+++ b/lib/engine-notice.mjs
@@ -0,0 +1,28 @@
+import { MIN_EVOLVER_VERSION } from './engine-version.mjs';
+
+const MANUAL_INSTALL = 'To set it up by hand: `npm install -g @evomap/evolver@latest`, then `evolver lifecycle start`.';
+
+function retryPhrase(outcome) {
+ return outcome.retryAt ? ` The plugin retries automatically after ${new Date(outcome.retryAt).toISOString()}.` : '';
+}
+
+const NOTICES = {
+ installed: (outcome) => `[Evolver] Evolver was missing, so this plugin installed Evolver ${outcome.version} and started its Proxy.`,
+ upgraded: (outcome) => `[Evolver] Evolver ${outcome.previous} was older than ${MIN_EVOLVER_VERSION}, so this plugin upgraded it to ${outcome.version} and started its Proxy.`,
+ started: (outcome) => `[Evolver] The Evolver Proxy was not running, so this plugin started it (Evolver ${outcome.version}).`,
+ restarted: (outcome) => `[Evolver] The running Evolver Proxy was older than ${MIN_EVOLVER_VERSION}, so this plugin restarted it on Evolver ${outcome.version}.`,
+ outdated: (outcome) => `[Evolver] Evolver ${outcome.version} is the newest this plugin could install, and reuse evidence needs ${MIN_EVOLVER_VERSION} or newer`
+ + `${outcome.installError ? ` (last upgrade attempt: ${outcome.installError})` : ''}. Recall works meanwhile; the plugin retries the upgrade every 6 hours.`,
+ restart_needed: (outcome) => `[Evolver] The running Evolver Proxy is ${outcome.proxyVersion ?? 'an older build'}, older than the installed ${outcome.version}, and this plugin did not start it. `
+ + 'Run `evolver lifecycle restart` (or restart the Proxy you started yourself) so reuse evidence reaches the Hub.',
+ node_unsupported: (outcome) => `[Evolver] Evolver needs Node.js 22.13 or newer, but Cursor runs this plugin's hooks with Node.js ${outcome.node}, so the plugin cannot install or start Evolver. `
+ + `Install a newer Node.js and make it the \`node\` on PATH.${retryPhrase(outcome)}`,
+ install_failed: (outcome) => `[Evolver] Installing Evolver failed (${outcome.error}). ${MANUAL_INSTALL}${retryPhrase(outcome)}`,
+ setup_error: (outcome) => `[Evolver] Setting up Evolver stopped on an unexpected error (${outcome.error}). ${MANUAL_INSTALL}${retryPhrase(outcome)}`,
+ start_failed: (outcome) => `[Evolver] Evolver ${outcome.version} is installed but its Proxy did not start (${outcome.error}). `
+ + `Check ~/.evomap/logs/evolver-proxy.log, then run \`evolver lifecycle start\`.${retryPhrase(outcome)}`,
+};
+
+export function engineNoticeOf(outcome) {
+ return NOTICES[outcome?.status]?.(outcome) ?? null;
+}
diff --git a/lib/engine-runtime.mjs b/lib/engine-runtime.mjs
new file mode 100644
index 0000000..e21a9b8
--- /dev/null
+++ b/lib/engine-runtime.mjs
@@ -0,0 +1,117 @@
+import { execFile } from 'node:child_process';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+import { versionOf } from './engine-version.mjs';
+import { runningProxy } from './proxy.mjs';
+import { statePath } from './state-file.mjs';
+
+const INSTALL_TIMEOUT_MS = 5 * 60 * 1000;
+const LIFECYCLE_TIMEOUT_MS = 60 * 1000;
+const PROBE_TIMEOUT_MS = 10 * 1000;
+const PROXY_WAIT_MS = 60 * 1000;
+const PROXY_POLL_MS = 1000;
+const ERROR_MAX_CHARS = 300;
+const PACKAGE = '@evomap/evolver';
+const IS_WINDOWS = process.platform === 'win32';
+
+export const engineDir = () => statePath('cursor-engine');
+
+// Windows argv parsing treats backslashes as literal unless they precede a
+// quote, so those runs (and a trailing run before the closing quote) double.
+export function windowsQuoted(arg) {
+ if (!/[\s"]/.test(arg)) return arg;
+ return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, '$1$1')}"`;
+}
+
+function run(command, args, { timeoutMs, env, shell = false }) {
+ return new Promise((resolve) => {
+ execFile(command, shell ? args.map(windowsQuoted) : args, { timeout: timeoutMs, encoding: 'utf8', env, shell, windowsHide: true, cwd: os.homedir() },
+ (error, stdout, stderr) => resolve({
+ ok: !error,
+ stdout: String(stdout ?? ''),
+ error: error ? String(stderr || error.message).trim().slice(-ERROR_MAX_CHARS) : null,
+ }));
+ });
+}
+
+// Evolver's first start otherwise migrates itself to a standalone binary and
+// registers a launchd/systemd/scheduled-task service, which a plugin must not
+// do on the person's behalf; an explicit setting of theirs still wins.
+function lifecycleEnv() {
+ return { ...process.env, EVOLVER_SELF_UPDATE: process.env.EVOLVER_SELF_UPDATE || 'off' };
+}
+
+function bundledNpmCli() {
+ const nodeDir = path.dirname(process.execPath);
+ const candidates = IS_WINDOWS
+ ? [path.join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js')]
+ : [path.join(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')];
+ return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
+}
+
+export function defaultNpmCommand() {
+ const cli = bundledNpmCli();
+ return cli ? { command: process.execPath, args: [cli], shell: false } : { command: 'npm', args: [], shell: IS_WINDOWS };
+}
+
+function managedPackageDir(prefix) {
+ return path.join(prefix, 'node_modules', ...PACKAGE.split('/'));
+}
+
+function managedEngineAt(prefix) {
+ try {
+ const manifest = JSON.parse(fs.readFileSync(path.join(managedPackageDir(prefix), 'package.json'), 'utf8'));
+ const binPath = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.evolver;
+ const entry = path.join(managedPackageDir(prefix), binPath);
+ if (!versionOf(manifest.version) || !fs.existsSync(entry)) return null;
+ return {
+ source: 'managed',
+ path: entry,
+ version: versionOf(manifest.version),
+ run: (args) => run(process.execPath, [entry, ...args], { timeoutMs: LIFECYCLE_TIMEOUT_MS, env: lifecycleEnv() }),
+ };
+ } catch {
+ return null;
+ }
+}
+
+async function pathEngine() {
+ const probe = await run('evolver', ['--version'], { timeoutMs: PROBE_TIMEOUT_MS, env: process.env, shell: IS_WINDOWS });
+ const version = probe.ok ? versionOf(probe.stdout) : null;
+ if (!version) return null;
+ return {
+ source: 'path',
+ path: 'evolver',
+ version,
+ run: (args) => run('evolver', args, { timeoutMs: LIFECYCLE_TIMEOUT_MS, env: lifecycleEnv(), shell: IS_WINDOWS }),
+ };
+}
+
+async function waitForProxy(deadlineMs, isWanted) {
+ const deadline = Date.now() + deadlineMs;
+ let seen = null;
+ while (Date.now() < deadline) {
+ seen = await runningProxy();
+ if (seen && isWanted(seen)) return seen;
+ await new Promise((resolve) => setTimeout(resolve, PROXY_POLL_MS));
+ }
+ return seen;
+}
+
+export function engineRuntime({ prefix = engineDir(), npm = defaultNpmCommand(), proxyWaitMs = PROXY_WAIT_MS } = {}) {
+ return {
+ nodeVersion: process.versions.node,
+ now: () => Date.now(),
+ managedEngine: async () => managedEngineAt(prefix),
+ pathEngine,
+ runningProxy,
+ waitForProxy: (isWanted = () => true) => waitForProxy(proxyWaitMs, isWanted),
+ installLatest: () => {
+ fs.mkdirSync(prefix, { recursive: true });
+ const args = [...npm.args, 'install', '--prefix', prefix, '--no-audit', '--no-fund', '--loglevel=error', `${PACKAGE}@latest`];
+ return run(npm.command, args, { timeoutMs: INSTALL_TIMEOUT_MS, env: process.env, shell: npm.shell });
+ },
+ };
+}
diff --git a/lib/engine-setup.mjs b/lib/engine-setup.mjs
new file mode 100644
index 0000000..f1a984b
--- /dev/null
+++ b/lib/engine-setup.mjs
@@ -0,0 +1,101 @@
+import { isOlderThan, meetsMinimum, nodeRunsEvolver } from './engine-version.mjs';
+import { withoutSecrets } from './redact.mjs';
+
+const RETRY_BASE_MS = 10 * 60 * 1000;
+const RETRY_MAX_MS = 24 * 60 * 60 * 1000;
+const OUTDATED_INSTALL_RETRY_MS = 6 * 60 * 60 * 1000;
+
+export const FAILURES = new Set(['node_unsupported', 'install_failed', 'start_failed', 'setup_error']);
+
+export function isSetupDue(previous, now) {
+ return !(previous?.retryAt > now);
+}
+
+function isInstallDue(previous, now) {
+ return !(previous?.installRetryAt > now);
+}
+
+function newerOf(left, right) {
+ if (!left) return right;
+ if (!right) return left;
+ return isOlderThan(left.version, right.version) ? right : left;
+}
+
+function usableEngine(managed, onPath) {
+ const usable = [managed, onPath].find((engine) => engine && meetsMinimum(engine.version));
+ return usable ?? newerOf(managed, onPath);
+}
+
+async function installedEngine(runtime, found) {
+ const install = await runtime.installLatest();
+ const managed = install.ok ? await runtime.managedEngine() : null;
+ if (!managed) return { engine: found, installError: install.error ?? 'npm finished but @evomap/evolver is missing' };
+ return { engine: managed, installed: found ? 'upgraded' : 'installed', previous: found?.version };
+}
+
+async function engineToRun(runtime, previous, now) {
+ const found = usableEngine(await runtime.managedEngine(), await runtime.pathEngine());
+ if (found && meetsMinimum(found.version)) return { engine: found };
+ if (!isInstallDue(previous, now)) return { engine: found };
+ return installedEngine(runtime, found);
+}
+
+async function proxyAfter(runtime, engine, action) {
+ const ran = await engine.run(['lifecycle', action]);
+ const running = await runtime.waitForProxy(action === 'restart' ? (proxy) => !isOutdated(proxy) : undefined);
+ return running ? { running } : { startError: ran.ok ? 'the Proxy did not answer within the wait' : ran.error };
+}
+
+async function ensureProxy(runtime, engine) {
+ const running = await runtime.runningProxy();
+ if (!running) return { action: 'started', ...(await proxyAfter(runtime, engine, 'start')) };
+ const isStale = isOutdated(running) && meetsMinimum(engine.version) && running.version !== engine.version;
+ if (!isStale) return { running };
+ return { action: 'restarted', ...(await proxyAfter(runtime, engine, 'restart')) };
+}
+
+function isOutdated(running) {
+ return !meetsMinimum(running?.version);
+}
+
+function retryDelay(failures) {
+ return Math.min(RETRY_BASE_MS * 2 ** Math.max(0, failures - 1), RETRY_MAX_MS);
+}
+
+function settled(outcome, previous, now) {
+ const failures = FAILURES.has(outcome.status) ? (previous?.failures ?? 0) + 1 : 0;
+ const isOutdated = outcome.version && !meetsMinimum(outcome.version);
+ return {
+ ...outcome,
+ at: now,
+ failures,
+ ...(failures > 0 && { retryAt: now + retryDelay(failures) }),
+ ...(isOutdated && { installRetryAt: previous?.installRetryAt > now ? previous.installRetryAt : now + OUTDATED_INSTALL_RETRY_MS }),
+ };
+}
+
+function statusOf({ installed, action, running, engine }) {
+ if (!meetsMinimum(engine.version)) return 'outdated';
+ if (isOutdated(running)) return 'restart_needed';
+ if (installed) return installed;
+ if (action === 'started' || action === 'restarted') return action;
+ return 'ready';
+}
+
+export function setupErrorOutcome(error, previous, now) {
+ return settled({ status: 'setup_error', error: withoutSecrets(String(error?.message ?? error)).slice(0, 300) }, previous, now);
+}
+
+export async function prepareEngine(runtime, previous) {
+ const now = runtime.now();
+ if (!nodeRunsEvolver(runtime.nodeVersion)) {
+ return settled({ status: 'node_unsupported', node: runtime.nodeVersion }, previous, now);
+ }
+ const { engine, installed, previous: replaced, installError } = await engineToRun(runtime, previous, now);
+ if (!engine) return settled({ status: 'install_failed', error: installError }, previous, now);
+ const proxy = await ensureProxy(runtime, engine);
+ const base = { version: engine.version, source: engine.source, ...(replaced && { previous: replaced }), ...(installError && { installError }) };
+ if (!proxy.running) return settled({ ...base, status: 'start_failed', installed, error: proxy.startError }, previous, now);
+ const status = statusOf({ installed, action: proxy.action, running: proxy.running, engine });
+ return settled({ ...base, status, proxyVersion: proxy.running.version ?? null }, previous, now);
+}
diff --git a/lib/engine-state.mjs b/lib/engine-state.mjs
new file mode 100644
index 0000000..1ab542b
--- /dev/null
+++ b/lib/engine-state.mjs
@@ -0,0 +1,20 @@
+import { readState, statePath, writeState } from './state-file.mjs';
+
+const enginePath = () => statePath('cursor-engine.json');
+const announcedPath = () => statePath('cursor-engine-announced.json');
+
+export function readEngineState() {
+ return readState(enginePath(), null);
+}
+
+export function writeEngineState(outcome) {
+ writeState(enginePath(), outcome);
+}
+
+export function isAnnounced(outcome) {
+ return readState(announcedPath(), {}).at === outcome?.at;
+}
+
+export function markAnnounced(outcome) {
+ writeState(announcedPath(), { at: outcome.at });
+}
diff --git a/lib/engine-version.mjs b/lib/engine-version.mjs
new file mode 100644
index 0000000..618cb36
--- /dev/null
+++ b/lib/engine-version.mjs
@@ -0,0 +1,36 @@
+export const MIN_EVOLVER_VERSION = '2.0.44';
+
+export function versionOf(text) {
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(String(text ?? ''));
+ return match ? match.slice(1, 4).join('.') : null;
+}
+
+function prereleaseOf(version) {
+ return /\d+\.\d+\.\d+-([0-9A-Za-z.-]+)/.exec(String(version ?? ''))?.[1] ?? null;
+}
+
+function isOlderPrerelease(have, need) {
+ if (have === null || need === null) return have !== null && need === null;
+ return have.localeCompare(need, 'en', { numeric: true }) < 0;
+}
+
+export function isOlderThan(version, minimum) {
+ const have = versionOf(version)?.split('.').map(Number);
+ const need = versionOf(minimum)?.split('.').map(Number);
+ if (!have || !need) return false;
+ for (let index = 0; index < 3; index += 1) {
+ if (have[index] !== need[index]) return have[index] < need[index];
+ }
+ return isOlderPrerelease(prereleaseOf(version), prereleaseOf(minimum));
+}
+
+export function meetsMinimum(version) {
+ return Boolean(versionOf(version)) && !isOlderThan(version, MIN_EVOLVER_VERSION);
+}
+
+export function nodeRunsEvolver(nodeVersion) {
+ const [major, minor] = (versionOf(nodeVersion) ?? '0.0.0').split('.').map(Number);
+ if (major === 22) return minor >= 13;
+ if (major === 23) return minor >= 4;
+ return major > 23;
+}
diff --git a/lib/feedback-label.mjs b/lib/feedback-label.mjs
new file mode 100644
index 0000000..9801e83
--- /dev/null
+++ b/lib/feedback-label.mjs
@@ -0,0 +1,12 @@
+import { looksLikeCorrection } from './dissatisfaction.mjs';
+
+const PRAISE_PATTERNS = [
+ /\b(?:thanks|thank you|thx|perfect|great|awesome|nice|excellent|works now|it works|that works|lgtm|looks good)\b/i,
+ /谢谢|感谢|完美|好了|可以了|搞定|没问题了|成功了|不错|很好|太好了|牛/,
+];
+
+export function feedbackLabelOf(text) {
+ if (typeof text !== 'string' || !text.trim()) return null;
+ if (looksLikeCorrection(text)) return 'correction';
+ return PRAISE_PATTERNS.some((pattern) => pattern.test(text)) ? 'praise' : null;
+}
diff --git a/lib/hook-io.mjs b/lib/hook-io.mjs
new file mode 100644
index 0000000..34d82b5
--- /dev/null
+++ b/lib/hook-io.mjs
@@ -0,0 +1,45 @@
+export const HEADLESS_MARKER = 'EVOLVER_HEADLESS';
+
+function parsedInput(raw) {
+ try {
+ const input = JSON.parse(raw);
+ return input && typeof input === 'object' ? input : {};
+ } catch {
+ return {};
+ }
+}
+
+export function sessionIdOf(input) {
+ const id = input?.conversation_id || input?.session_id;
+ return typeof id === 'string' && id ? id : null;
+}
+
+export function generationIdOf(input) {
+ return typeof input?.generation_id === 'string' && input.generation_id ? input.generation_id : null;
+}
+
+/** Whether this hook runs inside the headless distillation, which loads this plugin too. */
+export function isHeadlessRun(env = process.env) {
+ return env[HEADLESS_MARKER] === '1';
+}
+
+// Cursor writes the payload to stdin and reads the last JSON object on stdout; a hook that fails answers `{}`
+// rather than block the turn, and inside the headless distillation every hook does nothing.
+export function runHook(handle) {
+ let buffer = '';
+ const finish = (output) => process.stdout.write(JSON.stringify(output ?? {}), () => process.exit(0));
+ const start = async () => {
+ if (isHeadlessRun()) return finish({});
+ try {
+ finish(await handle(parsedInput(buffer)));
+ } catch {
+ finish({});
+ }
+ };
+ process.stdin.setEncoding('utf8');
+ process.stdin.on('data', (chunk) => {
+ buffer += chunk;
+ });
+ process.stdin.once('end', start);
+ process.stdin.once('error', start);
+}
diff --git a/lib/injected-assets.mjs b/lib/injected-assets.mjs
new file mode 100644
index 0000000..f80eb55
--- /dev/null
+++ b/lib/injected-assets.mjs
@@ -0,0 +1,93 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+import { ownerIsGone } from './process-owner.mjs';
+import { hashedName, readState, removeState, stateDir, statePath, updateState } from './state-file.mjs';
+
+const ENTRY_PRUNE_MS = 7 * 24 * 60 * 60 * 1000;
+const LEDGER_PREFIX = 'cursor-injected';
+
+function pruned(ledger, now) {
+ return Object.fromEntries(Object.entries(ledger ?? {}).filter(([, entry]) => {
+ const at = Number(entry?.injectedAt);
+ return Number.isFinite(at) && now - at <= ENTRY_PRUNE_MS;
+ }));
+}
+
+/**
+ * Every entry in a ledger belongs to the session it is named after, so each injection also stamps the
+ * entries already there with the live owner: an entry written by the Cursor process a reopened chat ran in
+ * would otherwise read as an orphaned one.
+ */
+const owned = (ledger, sessionId, pid) =>
+ Object.fromEntries(Object.entries(ledger).map(([id, entry]) => [id, { ...entry, sessionId, pid }]));
+
+export function withInjected(ledger, assetId, { turn, strategyText, sessionId, pid }, now) {
+ const kept = owned(pruned(ledger, now), sessionId, pid);
+ if (kept[assetId]) return kept;
+ return { ...kept, [assetId]: { turn, injectedAt: now, strategyText, sessionId, pid } };
+}
+
+export function unreportedOf(ledger, turn) {
+ return Object.entries(ledger ?? {})
+ .filter(([, entry]) => !entry?.reportedAt && Number(entry?.turn) <= turn)
+ .map(([assetId, entry]) => ({ assetId, strategyText: entry.strategyText ?? '', turn: entry.turn, sessionId: entry.sessionId }));
+}
+
+export function withReported(ledger, assetIds, now) {
+ const kept = pruned(ledger, now);
+ for (const assetId of assetIds) if (kept[assetId]) kept[assetId] = { ...kept[assetId], reportedAt: now };
+ return kept;
+}
+
+export const ledgerPath = (sessionKey) => statePath(hashedName(LEDGER_PREFIX, sessionKey));
+
+export function injectedIds(sessionKey) {
+ return new Set(Object.keys(readState(ledgerPath(sessionKey), {})));
+}
+
+/** `pid` is the Cursor process the session runs in, or null where none is known (never taken for gone). */
+export function rememberInjected(sessionKey, assetId, { pid, ...injection }, now = Date.now()) {
+ if (!sessionKey) return;
+ updateState(ledgerPath(sessionKey), {}, (ledger) =>
+ withInjected(ledger, assetId, { ...injection, sessionId: sessionKey, pid }, now));
+}
+
+export function takeUnreported(sessionKey, turn, now = Date.now()) {
+ if (!sessionKey) return [];
+ let due = [];
+ updateState(ledgerPath(sessionKey), {}, (ledger) => {
+ due = unreportedOf(ledger, turn);
+ return due.length > 0 ? withReported(ledger, due.map((asset) => asset.assetId), now) : undefined;
+ });
+ return due;
+}
+
+/**
+ * Injections no turn end ever reported, from sessions whose Cursor process is gone: a process killed mid-turn
+ * leaves them behind. Each ledger's are handed to `queue` and only then marked reported, so a queue that
+ * fails leaves them for the next session start; a report queued twice settles once in the outbox. A session
+ * that is merely idle keeps its open injections.
+ */
+export function queueOrphanedUnreported(queue, { isGone = ownerIsGone, now = Date.now() } = {}) {
+ let files = [];
+ try {
+ files = fs.readdirSync(stateDir()).filter((file) => file.startsWith(`${LEDGER_PREFIX}-`) && file.endsWith('.json'));
+ } catch {
+ return;
+ }
+ for (const file of files) {
+ updateState(path.join(stateDir(), file), {}, (ledger) => {
+ const due = Object.entries(ledger)
+ .filter(([, entry]) => !entry?.reportedAt && entry?.sessionId && isGone(entry.pid))
+ .map(([assetId, entry]) => ({ assetId, strategyText: entry.strategyText ?? '', turn: entry.turn, sessionId: entry.sessionId }));
+ if (due.length === 0) return undefined;
+ queue(due);
+ return withReported(ledger, due.map((asset) => asset.assetId), now);
+ });
+ }
+}
+
+export function forgetSession(sessionKey) {
+ if (sessionKey) removeState(ledgerPath(sessionKey));
+}
diff --git a/lib/local-ledger.mjs b/lib/local-ledger.mjs
new file mode 100644
index 0000000..cd5298a
--- /dev/null
+++ b/lib/local-ledger.mjs
@@ -0,0 +1,73 @@
+import { createRequire } from 'node:module';
+import path from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+import { engineDir } from './engine-runtime.mjs';
+
+const HIT_EVENT = 'value.reuse_hit';
+const OUTCOME_EVENT = 'value.reuse_outcome';
+const MAX_TITLE_CHARS = 80;
+const CORE_PACKAGE = '@evomap/evolver-core';
+const ENGINE_PACKAGE = path.join('node_modules', '@evomap', 'evolver', 'package.json');
+
+function globalPackageRoots() {
+ const nodeDir = path.dirname(process.execPath);
+ return process.platform === 'win32' ? [path.join(nodeDir, ENGINE_PACKAGE)] : [path.join(nodeDir, '..', 'lib', ENGINE_PACKAGE)];
+}
+
+// This plugin ships no node_modules, so the core is resolved through the Evolver it set up, or the global one.
+export function evolverCoreEntry(manifests = [path.join(engineDir(), ENGINE_PACKAGE), ...globalPackageRoots()]) {
+ for (const manifest of manifests) {
+ try {
+ return createRequire(manifest).resolve(CORE_PACKAGE);
+ } catch {
+ }
+ }
+ return null;
+}
+
+const byPath = new Map();
+
+async function loadIngestor() {
+ const entry = evolverCoreEntry();
+ if (!entry) return null;
+ try {
+ const { events } = await import(pathToFileURL(entry).href);
+ const eventsPath = events.rootEventsPath();
+ if (!byPath.has(eventsPath)) byPath.set(eventsPath, new events.Ingestor({ path: eventsPath }));
+ return byPath.get(eventsPath);
+ } catch {
+ return null;
+ }
+}
+
+function cycleIdOf(sessionId) {
+ return `cursor:${sessionId || 'unknown'}`;
+}
+
+// The verdict has to become a root_event to be worth anything: the actuator
+// that re-orders candidates reads root_events, not the Hub, and a Hub without
+// the route records nothing at all. Success and the negative verdicts ride
+// different events by design -- the ledger derives savings from one and the
+// keep/prune signal from the other.
+export async function recordReuseLocally({ assetId, outcome, sessionId }) {
+ if (!assetId || !outcome) return false;
+ const ingestor = await loadIngestor();
+ if (!ingestor) return false;
+
+ const cycleId = cycleIdOf(sessionId);
+ const success = outcome === 'success';
+ try {
+ await ingestor.ingest({
+ type: success ? HIT_EVENT : OUTCOME_EVENT,
+ human: {
+ title: `cursor reuse ${outcome}`.slice(0, MAX_TITLE_CHARS),
+ detail: `cycle ${cycleId}`,
+ },
+ payload: success ? { assetId, cycleId } : { assetId, cycleId, outcome },
+ });
+ return true;
+ } catch {
+ return false;
+ }
+}
diff --git a/lib/plugin-commands.mjs b/lib/plugin-commands.mjs
new file mode 100644
index 0000000..7744456
--- /dev/null
+++ b/lib/plugin-commands.mjs
@@ -0,0 +1,29 @@
+import { evolverCommands } from './commands.mjs';
+import { cursorModelRunner } from './cursor-agent.mjs';
+import { DISTILL_TIMEOUT_MS, distillSession } from './distiller.mjs';
+import { createProxyClient } from './proxy.mjs';
+import { statusReport } from './status-report.mjs';
+
+const SEARCH_LIMIT = 5;
+
+/** The plugin-run commands, wired to the Proxy and to this chat's own distillation candidate. */
+export function pluginCommands(sessionId) {
+ const proxyFetch = createProxyClient();
+ return evolverCommands({
+ status: () => statusReport(proxyFetch),
+ search: (text) => proxyFetch('POST', '/asset/search', { text, limit: SEARCH_LIMIT }),
+ distillNow: (focus) => distillSession(sessionId, {
+ proxyFetch: createProxyClient({ timeoutMs: DISTILL_TIMEOUT_MS }),
+ runModel: cursorModelRunner(),
+ focus,
+ }),
+ });
+}
+
+/** What a typed plugin command answers in the chat, or null when the prompt is not one. */
+export async function answerOfCommand(typed, sessionId) {
+ const command = typed && pluginCommands(sessionId).find((candidate) => candidate.name === typed.name);
+ if (!command) return null;
+ const { text } = await command.handler({ args: typed.args });
+ return text;
+}
diff --git a/lib/prime.mjs b/lib/prime.mjs
new file mode 100644
index 0000000..4187633
--- /dev/null
+++ b/lib/prime.mjs
@@ -0,0 +1,175 @@
+import { withoutSecrets } from './redact.mjs';
+
+// One round trip, not two: since evolver 2.0.39 `/asset/fetch` recalls by text
+// when it is given no ids, so selecting a candidate and materialising it are the
+// same call. No `limit` is sent, so the Hub's own default decides how many come
+// back (two at present).
+const MIN_PROMPT_CHARS = 8;
+const PROMPT_MAX_CHARS = 400;
+const STEP_MAX_CHARS = 400;
+const TITLE_MAX_CHARS = 80;
+const DEFAULT_MIN_SIMILARITY = 0.3;
+// Both bounds live here because recall does not pass through the Hub's search
+// tuning: `thinGeneClause`, which drops genes of three steps or fewer, is
+// imported only by assetSearchService, so the route that selects by ids and the
+// route that recalls by text disagree about what is worth returning. Measured
+// against a live Hub over five prompts, recall handed back three-step genes and
+// a two-step one. The upper bound is on what the injection costs, not on how
+// many steps carry it: a cap of eight steps dropped half of all recalled assets
+// (20 of 39 over eight prompts), and for broad prompts every candidate, while
+// 9-15 step genes inject a median 2690 characters and the oversized pipeline
+// transcripts that motivated a cap run past 4700.
+const MIN_STRATEGY_STEPS = 4;
+export const STRATEGY_MAX_CHARS = 4000;
+const UNSCORED = -1;
+const EMPTY_MATCH = { ids: [], text: '' };
+
+function strategySteps(asset) {
+ const steps = asset?.strategy ?? asset?.payload?.strategy ?? asset?.gene?.strategy;
+ const list = Array.isArray(steps) ? steps : [steps];
+ return list
+ .filter((step) => typeof step === 'string' && step.trim())
+ .map((step) => step.trim().slice(0, STEP_MAX_CHARS));
+}
+
+function strategyChars(steps) {
+ return steps.reduce((total, step) => total + step.length, 0);
+}
+
+export function promptTextOf(prompt) {
+ return withoutSecrets(typeof prompt === 'string' ? prompt : '').trim().slice(0, PROMPT_MAX_CHARS);
+}
+
+function recalledAssets(data) {
+ const found = [data?.assets, data?.results, data?.payload?.results].find(Array.isArray) ?? [];
+ return found.filter((asset) => asset && typeof asset.asset_id === 'string' && asset.asset_id);
+}
+
+const CJK_SCRIPT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
+const MIN_CJK_TITLE_CHARS = 5;
+
+function trimmedText(value) {
+ return typeof value === 'string' && value.trim() ? value.trim() : '';
+}
+
+// A Hub short_title is sometimes a truncated fragment: `Object`, `自动化小`.
+// Character count cannot separate those from a good title, because `智能缓存优化`
+// says as much in six characters as a Latin title says in forty.
+function looksLikeAName(text) {
+ if (!text) return false;
+ if (CJK_SCRIPT.test(text)) return [...text].length >= MIN_CJK_TITLE_CHARS;
+ return /\s/.test(text);
+}
+
+function readableNameOf(asset) {
+ const title = trimmedText(asset?.short_title);
+ if (looksLikeAName(title)) return title.slice(0, TITLE_MAX_CHARS);
+ const described = trimmedText(asset?.nl_summary) || trimmedText(asset?.summary);
+ if (described) return described.slice(0, TITLE_MAX_CHARS);
+ return title || asset?.asset_type || asset?.type || 'Gene';
+}
+
+function similarityOf(asset) {
+ return typeof asset.similarity === 'number' ? asset.similarity : UNSCORED;
+}
+
+// Recall hands back whole assets, so having a strategy is read off the asset
+// itself rather than trusted from a search flag. What is left to judge is the
+// score, and it is only advisory: it swings with phrasing — the same React
+// question scored 0.88 asked one way and 0.40 asked another, while boilerplate
+// and off-topic hits sit at 0.19–0.22 — and a Proxy that reports none at all
+// must not be filtered down to nothing. They rank by score rather than by the
+// order the Hub listed them in, since that order weighs more than this prompt.
+function rankingRejection(asset, listedIds, minSimilarity) {
+ if (listedIds.has(asset.asset_id)) return 'already_injected';
+ if (typeof asset.similarity === 'number' && asset.similarity < minSimilarity) return 'low_similarity';
+ return null;
+}
+
+function strategyRejection(steps) {
+ if (steps.length < MIN_STRATEGY_STEPS) return 'too_few_steps';
+ if (strategyChars(steps) > STRATEGY_MAX_CHARS) return 'too_long';
+ return null;
+}
+
+async function proxyJson(proxyFetch, path, body, signal) {
+ try {
+ const result = await proxyFetch('POST', path, body, signal);
+ return result?.ok ? { data: result.data } : { error: String(result?.error ?? 'Proxy returned no result') };
+ } catch (error) {
+ return { error: String(error?.message ?? error) };
+ }
+}
+
+// Priming sits on the critical path of every answer: an unreachable Proxy, a
+// slow Hub, or a malformed body must cost the turn nothing but the deadline.
+// One asset's strategy is injected and nothing else — a summary only tells the
+// model that something exists, while the steps are what it can actually reuse.
+// Every lookup reports why it ended through `onOutcome`, because an empty match
+// alone cannot tell a Proxy error from a recall that found nothing usable.
+export async function hubGene(proxyFetch, text, {
+ signal,
+ listedIds = new Set(),
+ minSimilarity = DEFAULT_MIN_SIMILARITY,
+ onOutcome = () => {},
+} = {}) {
+ if (text.length < MIN_PROMPT_CHARS) {
+ onOutcome({ status: 'skipped', reason: 'prompt_too_short' });
+ return EMPTY_MATCH;
+ }
+
+ const recalled = await proxyJson(proxyFetch, '/asset/fetch', { text }, signal);
+ if (recalled.error) {
+ onOutcome({ status: 'proxy_error', error: recalled.error });
+ return EMPTY_MATCH;
+ }
+
+ const assets = recalledAssets(recalled.data);
+ if (assets.length === 0) {
+ onOutcome({ status: 'no_candidates' });
+ return EMPTY_MATCH;
+ }
+
+ const dropped = {};
+ const drop = (reason) => {
+ dropped[reason] = (dropped[reason] ?? 0) + 1;
+ };
+ const ranked = assets
+ .filter((asset) => {
+ const reason = rankingRejection(asset, listedIds, minSimilarity);
+ if (reason) drop(reason);
+ return !reason;
+ })
+ .sort((left, right) => similarityOf(right) - similarityOf(left));
+
+ for (const candidate of ranked) {
+ const steps = strategySteps(candidate);
+ const reason = strategyRejection(steps);
+ if (reason) {
+ drop(reason);
+ continue;
+ }
+
+ onOutcome({
+ status: 'injected',
+ candidates: assets.length,
+ dropped,
+ asset_id: candidate.asset_id,
+ title: readableNameOf(candidate),
+ steps: steps.length,
+ chars: strategyChars(steps),
+ similarity: typeof candidate.similarity === 'number' ? candidate.similarity : null,
+ });
+ return {
+ ids: [candidate.asset_id],
+ text: [
+ `[Evolution Memory] ${readableNameOf(candidate)} (EvoMap network):`,
+ ...steps.map((step, index) => `${index + 1}. ${step}`),
+ '',
+ 'Apply them where they fit.',
+ ].join('\n'),
+ };
+ }
+ onOutcome({ status: 'all_filtered', candidates: assets.length, dropped });
+ return EMPTY_MATCH;
+}
diff --git a/lib/process-owner.mjs b/lib/process-owner.mjs
new file mode 100644
index 0000000..9aa6bf2
--- /dev/null
+++ b/lib/process-owner.mjs
@@ -0,0 +1,65 @@
+import { execFileSync } from 'node:child_process';
+import { readlinkSync } from 'node:fs';
+import path from 'node:path';
+
+const CURSOR_EXECUTABLE = /cursor/i;
+const PS_TIMEOUT_MS = 1_000;
+
+/**
+ * Whether the process that wrote a state entry has exited. Signal 0 only probes: ESRCH means no such process;
+ * EPERM means it exists under another user. An entry without a pid is never taken for a dead owner's.
+ */
+export function ownerIsGone(pid) {
+ if (!Number.isInteger(pid) || pid <= 0) return false;
+ if (pid === process.pid) return false;
+ try {
+ process.kill(pid, 0);
+ return false;
+ } catch (error) {
+ return error?.code === 'ESRCH';
+ }
+}
+
+export function processTableOf(psOutput) {
+ const table = new Map();
+ for (const line of String(psOutput).split('\n')) {
+ const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line);
+ if (match) table.set(Number(match[1]), { ppid: Number(match[2]), command: match[3] });
+ }
+ return table;
+}
+
+function processTable() {
+ try {
+ return processTableOf(execFileSync('ps', ['-A', '-o', 'pid=,ppid=,comm='], { encoding: 'utf8', timeout: PS_TIMEOUT_MS }));
+ } catch {
+ return new Map();
+ }
+}
+
+// Linux's ps names a process by at most 15 characters of its executable (`node` for the CLI), so the full path
+// comes from /proc; macOS's ps gives it already.
+function executablePathOf(pid, command) {
+ if (path.isAbsolute(command)) return command;
+ try {
+ return readlinkSync(`/proc/${pid}/exe`);
+ } catch {
+ return command;
+ }
+}
+
+/**
+ * The Cursor process this hook runs for. The editor starts hooks from its extension host, sometimes through a
+ * shell, and the CLI from its own process, so the nearest ancestor whose executable path names Cursor
+ * (`Cursor Helper (Plugin)`, `cursor-agent`) outlives the hook and dies with the window or CLI that runs the
+ * session. Null where no process table can be read (Windows).
+ */
+export function hostProcess(start = process.ppid, table = processTable()) {
+ for (let pid = start; pid > 1;) {
+ const entry = table.get(pid);
+ if (!entry) return null;
+ if (CURSOR_EXECUTABLE.test(executablePathOf(pid, entry.command))) return { pid };
+ pid = entry.ppid;
+ }
+ return null;
+}
diff --git a/lib/project-dir.mjs b/lib/project-dir.mjs
new file mode 100644
index 0000000..c0409d2
--- /dev/null
+++ b/lib/project-dir.mjs
@@ -0,0 +1,17 @@
+import fs from 'node:fs';
+
+function firstWorkspaceRoot(input) {
+ const [root] = Array.isArray(input?.workspace_roots) ? input.workspace_roots : [];
+ return typeof root === 'string' && root ? root : null;
+}
+
+// Plugin hooks run from the plugin's own folder (stop from the workspace root), so the project is named by the
+// environment and payload Cursor gives every hook rather than by the working directory.
+export function projectDirOf(input, env = process.env) {
+ const candidate = env.CURSOR_PROJECT_DIR || firstWorkspaceRoot(input) || process.cwd();
+ try {
+ return fs.realpathSync(candidate);
+ } catch {
+ return candidate;
+ }
+}
diff --git a/lib/proxy.mjs b/lib/proxy.mjs
new file mode 100644
index 0000000..4eb1886
--- /dev/null
+++ b/lib/proxy.mjs
@@ -0,0 +1,131 @@
+import { readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+const DEFAULT_REQUEST_TIMEOUT_MS = 8_000;
+const STATUS_TIMEOUT_MS = 1_500;
+const DEFAULT_PORT = '19820';
+
+function isLoopbackHost(hostname) {
+ const value = String(hostname || '').toLowerCase();
+ return value === 'localhost' || value === '127.0.0.1' || value === '[::1]' || value === '::1' || value.endsWith('.localhost');
+}
+
+export function normalizeLoopbackUrl(value) {
+ try {
+ const parsed = new URL(String(value));
+ if (!['http:', 'https:'].includes(parsed.protocol)) return null;
+ if (!isLoopbackHost(parsed.hostname) || parsed.username || parsed.password) return null;
+ parsed.hash = '';
+ parsed.pathname = parsed.pathname.replace(/\/+$/, '');
+ return parsed.toString().replace(/\/+$/, '');
+ } catch {
+ return null;
+ }
+}
+
+function settingsPath() {
+ return join(homedir(), '.evolver', 'settings.json');
+}
+
+function readSettingsProxy() {
+ try {
+ const proxy = JSON.parse(readFileSync(settingsPath(), 'utf8'))?.proxy;
+ return proxy && typeof proxy === 'object' ? proxy : null;
+ } catch {
+ return null;
+ }
+}
+
+function readProxySettings(port) {
+ const proxy = readSettingsProxy();
+ const url = proxy?.url ? normalizeLoopbackUrl(proxy.url) : null;
+ // The token belongs to the URL it was written with; a default port may be served by another process.
+ const token = url && proxy?.token ? String(proxy.token) : null;
+ return { url: url ?? `http://127.0.0.1:${port}`, token, fromSettings: url !== null };
+}
+
+// The Proxy records its own version and pid in settings.json when it starts, so
+// this is the version actually serving requests — which can differ from the
+// `evolver` found on PATH when another install started the Proxy.
+export function describeProxyEndpoint(port = process.env.EVOMAP_PROXY_PORT || DEFAULT_PORT) {
+ const proxy = readSettingsProxy();
+ const { url, token, fromSettings } = readProxySettings(port);
+ const fieldOf = (key) => (typeof proxy?.[key] === 'string' || typeof proxy?.[key] === 'number' ? proxy[key] : null);
+ return {
+ url,
+ url_source: fromSettings ? settingsPath() : `default port ${port}`,
+ token_present: token !== null,
+ running_version: fieldOf('version'),
+ pid: fieldOf('pid'),
+ started_at: fieldOf('started_at'),
+ };
+}
+
+const START_HINT =
+ 'Start it with `evolver lifecycle start`. ' +
+ 'Set EVOMAP_PROXY_PORT if you use a non-default port.';
+
+function httpErrorHint(status, base, token) {
+ if (status === 401 || status === 403) {
+ return token
+ ? ' The Proxy token in ~/.evolver/settings.json was rejected; restart Evolver so it writes fresh settings.'
+ : ` No Proxy token was found and the request was rejected — another process may be using ${base}. ${START_HINT}`;
+ }
+ if (status === 404) return ` Endpoint not found at ${base} — upgrade or verify the Evolver Proxy.`;
+ return '';
+}
+
+export function createProxyClient({
+ port = process.env.EVOMAP_PROXY_PORT || DEFAULT_PORT,
+ timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
+} = {}) {
+ return async function proxyFetch(method, requestPath, body, callerSignal) {
+ const { url: base, token } = readProxySettings(port);
+ const headers = {};
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
+ if (token) headers.Authorization = `Bearer ${token}`;
+
+ let response;
+ let text;
+ try {
+ const timeout = AbortSignal.timeout(timeoutMs);
+ const signal = callerSignal ? AbortSignal.any([callerSignal, timeout]) : timeout;
+ response = await fetch(base + requestPath, {
+ method,
+ headers: Object.keys(headers).length > 0 ? headers : undefined,
+ body: body === undefined ? undefined : JSON.stringify(body),
+ signal,
+ });
+ text = await response.text();
+ } catch (error) {
+ if (callerSignal?.aborted) return { ok: false, error: 'Proxy request cancelled.' };
+ const what = error?.name === 'TimeoutError' ? 'Proxy request timed out' : `Proxy connection failed: ${error?.message}`;
+ return { ok: false, unreachable: true, error: `${what}. Evolver Proxy not reachable at ${base}. ${START_HINT}` };
+ }
+
+ let data;
+ try {
+ data = text ? JSON.parse(text) : {};
+ } catch {
+ data = { raw: text };
+ }
+
+ if (!response.ok) {
+ return {
+ ok: false,
+ error:
+ `Proxy at ${base} returned HTTP ${response.status}: ${JSON.stringify(data)}.` +
+ httpErrorHint(response.status, base, token),
+ };
+ }
+ return { ok: true, data };
+ };
+}
+
+export async function runningProxy(proxyFetch = createProxyClient({ timeoutMs: STATUS_TIMEOUT_MS })) {
+ const result = await proxyFetch('GET', '/proxy/status');
+ if (!result.ok || result.data?.status !== 'running') return null;
+ const version = describeProxyEndpoint().running_version;
+ return { version: typeof version === 'string' ? version : null };
+}
diff --git a/lib/recall-log.mjs b/lib/recall-log.mjs
new file mode 100644
index 0000000..aba853a
--- /dev/null
+++ b/lib/recall-log.mjs
@@ -0,0 +1,14 @@
+import { readState, statePath, updateState } from './state-file.mjs';
+
+const recallLogPath = () => statePath('cursor-recall-log.json');
+
+export function recordRecall(outcome, now = Date.now()) {
+ updateState(recallLogPath(), {}, (log) => ({
+ last: { ...outcome, at: new Date(now).toISOString() },
+ counts: { ...log.counts, [outcome.status]: (log.counts?.[outcome.status] ?? 0) + 1 },
+ }));
+}
+
+export function readRecallLog() {
+ return readState(recallLogPath(), {});
+}
diff --git a/lib/recording-hook.mjs b/lib/recording-hook.mjs
new file mode 100644
index 0000000..6d0b333
--- /dev/null
+++ b/lib/recording-hook.mjs
@@ -0,0 +1,12 @@
+import { generationIdOf, runHook, sessionIdOf } from './hook-io.mjs';
+import { recordingTurnOf } from './session-state.mjs';
+
+/** Runs `note(sessionId, turnId, input)` for a hook of the session's open turn; one of a closed or older turn records nothing. */
+export function runRecordingHook(note) {
+ runHook((input) => {
+ const sessionId = sessionIdOf(input);
+ const turnId = sessionId ? recordingTurnOf(sessionId, generationIdOf(input)) : undefined;
+ if (turnId !== undefined) note(sessionId, turnId, input);
+ return {};
+ });
+}
diff --git a/lib/redact.mjs b/lib/redact.mjs
new file mode 100644
index 0000000..3e7915a
--- /dev/null
+++ b/lib/redact.mjs
@@ -0,0 +1,21 @@
+const SECRET_PATTERNS = [
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
+ /\b(?:sk|pk|rk)-(?:ant-|proj-|live-|test-)?[A-Za-z0-9_-]{16,}/g,
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/g,
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
+ /\bglpat-[A-Za-z0-9_-]{20,}/g,
+ /\bxox[abposr]-[A-Za-z0-9-]{10,}/g,
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
+ /\bAIza[A-Za-z0-9_-]{30,}/g,
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
+ /\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/gi,
+ /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@/]+@/gi,
+ /\b(?:\w*[_-])?(?:api[_-]?key|secret|token|passw(?:or)?d|pwd|access[_-]?key|private[_-]?key|client[_-]?secret)\b\s*[:=]\s*(?:"[^"\n]{6,}"|'[^'\n]{6,}'|["']?[^\s"',;]{6,})/gi,
+];
+
+export const REDACTED = '[redacted]';
+
+export function withoutSecrets(text) {
+ if (typeof text !== 'string') return '';
+ return SECRET_PATTERNS.reduce((cleaned, pattern) => cleaned.replace(pattern, REDACTED), text);
+}
diff --git a/lib/report-outbox.mjs b/lib/report-outbox.mjs
new file mode 100644
index 0000000..e30d438
--- /dev/null
+++ b/lib/report-outbox.mjs
@@ -0,0 +1,36 @@
+export const OUTBOX_TTL_MS = 8 * 24 * 60 * 60 * 1000;
+
+const keyOf = (report) => `${report.task_id}\0${report.asset_id}`;
+
+function pruned(outbox, now) {
+ return Object.fromEntries(Object.entries(outbox ?? {}).filter(([, entry]) => now - Number(entry?.queuedAt) <= OUTBOX_TTL_MS));
+}
+
+export function withQueued(outbox, queued, now) {
+ const kept = pruned(outbox, now);
+ for (const { report, sessionId } of queued) {
+ kept[keyOf(report)] ??= { report, sessionId, queuedAt: now, attempts: 0 };
+ }
+ return kept;
+}
+
+export const SENT = 'sent';
+export const REJECTED = 'rejected';
+export const RETRY = 'retry';
+
+export function isSettled(entry) {
+ return Boolean(entry.sentAt || entry.rejectedAt);
+}
+
+function settlementOf({ verdict, reason, status, receipt }, now) {
+ if (verdict === SENT) return { sentAt: now, ...(receipt && { receipt }) };
+ if (verdict === REJECTED) return { rejectedAt: now, rejection: { reason, ...(status !== undefined && { status }) } };
+ return {};
+}
+
+export function withAttempt(outbox, key, attempt, now) {
+ const entry = outbox[key];
+ if (!entry) return outbox;
+ const local = attempt.isLocal ? { localAt: now } : {};
+ return { ...outbox, [key]: { ...entry, attempts: entry.attempts + 1, ...local, ...settlementOf(attempt, now) } };
+}
diff --git a/lib/report-queue.mjs b/lib/report-queue.mjs
new file mode 100644
index 0000000..522c78f
--- /dev/null
+++ b/lib/report-queue.mjs
@@ -0,0 +1,37 @@
+import { withQueued } from './report-outbox.mjs';
+import { deliverPending } from './reuse.mjs';
+import { appendStateLine, readState, statePath, takeStateLines, withLock, writeState } from './state-file.mjs';
+
+const MAX_DRAIN_ROUNDS = 5;
+
+export const outboxPath = () => statePath('cursor-reports-outbox.json');
+const queuePath = () => statePath('cursor-reports-queue.jsonl');
+
+export function queueReports(queued) {
+ for (const entry of queued) appendStateLine(queuePath(), entry);
+}
+
+function attemptedEntries(before, after) {
+ return Object.fromEntries(Object.entries(after).filter(([key, entry]) => before[key] !== entry));
+}
+
+// Hooks only append to the queue file; the one worker holding the outbox lock
+// owns the outbox, and only the attempted entries are merged back, so a slow
+// drain never rolls the file back.
+export function drainOutbox(ledgers, clock = () => Date.now()) {
+ return withLock('cursor-outbox', async () => {
+ const attempted = new Set();
+ for (let round = 0; round < MAX_DRAIN_ROUNDS; round += 1) {
+ const { lines: queued, commit } = takeStateLines(queuePath());
+ const now = clock();
+ const outbox = withQueued(readState(outboxPath(), {}), queued, now);
+ writeState(outboxPath(), outbox);
+ commit();
+ const due = Object.fromEntries(Object.entries(outbox).filter(([key]) => !attempted.has(key)));
+ for (const key of Object.keys(due)) attempted.add(key);
+ const delivered = await deliverPending(ledgers, due, now);
+ writeState(outboxPath(), { ...readState(outboxPath(), {}), ...attemptedEntries(due, delivered) });
+ if (queued.length === 0) return;
+ }
+ });
+}
diff --git a/lib/retention-pending.mjs b/lib/retention-pending.mjs
new file mode 100644
index 0000000..a3ffad9
--- /dev/null
+++ b/lib/retention-pending.mjs
@@ -0,0 +1,39 @@
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+
+import { OUTBOX_TTL_MS } from './report-outbox.mjs';
+import { ABSENT, retentionOf } from './retention.mjs';
+import { retentionReportOf } from './reuse.mjs';
+import { appendStateLine, statePath, takeStateLines } from './state-file.mjs';
+
+const pendingPath = () => statePath('cursor-retention-pending.jsonl');
+
+export function contentHashOf(file) {
+ try {
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
+ } catch {
+ return ABSENT;
+ }
+}
+
+export function rememberForRetention({ sessionId, projectDir, reuses, editedBefore, now = Date.now() }) {
+ if (reuses.length === 0 || editedBefore.size === 0) return;
+ const files = [...editedBefore].map(([file, before]) => ({ path: file, before, after: contentHashOf(file) }));
+ appendStateLine(pendingPath(), { sessionId, projectDir, reuses, files, atMs: now });
+}
+
+export function takeDueRetentions({ sessionId, projectDir, now = Date.now() }) {
+ const { lines, commit } = takeStateLines(pendingPath(), now);
+ const pending = lines.filter((entry) => now - entry.atMs <= OUTBOX_TTL_MS);
+ const isDue = (entry) => entry.projectDir === projectDir && entry.sessionId !== sessionId;
+ for (const entry of pending.filter((candidate) => !isDue(candidate))) appendStateLine(pendingPath(), entry);
+ return { due: pending.filter(isDue), commit };
+}
+
+export function retentionReportsOf(entry) {
+ const verdict = retentionOf(entry.files.map((file) => ({ ...file, now: contentHashOf(file.path) }))).class;
+ return entry.reuses
+ .map((reuse) => retentionReportOf(reuse, verdict))
+ .filter((report) => report !== null)
+ .map((report) => ({ report, sessionId: entry.sessionId }));
+}
diff --git a/lib/retention.mjs b/lib/retention.mjs
new file mode 100644
index 0000000..d2ccd31
--- /dev/null
+++ b/lib/retention.mjs
@@ -0,0 +1,15 @@
+export const ABSENT = 'absent';
+
+function fileFateOf({ before, after, now }) {
+ if (now === after) return 'kept';
+ if (now === before) return 'reverted';
+ return 'rewritten';
+}
+
+export function retentionOf(files) {
+ const changed = (files ?? []).filter(({ before, after }) => before !== after);
+ if (changed.length === 0) return { class: 'no_change', files: 0 };
+ const fates = new Set(changed.map(fileFateOf));
+ const verdict = fates.size === 1 ? [...fates][0] : 'rewritten';
+ return { class: verdict, files: changed.length };
+}
diff --git a/lib/reuse.mjs b/lib/reuse.mjs
new file mode 100644
index 0000000..3cc87aa
--- /dev/null
+++ b/lib/reuse.mjs
@@ -0,0 +1,135 @@
+import { UNKNOWN } from './applied.mjs';
+import { checkEvidenceOf } from './checks.mjs';
+import { REJECTED, RETRY, SENT, isSettled, withAttempt } from './report-outbox.mjs';
+import { outcomeOfReason } from './turn-outcomes.mjs';
+
+const LABEL_OUTCOMES = { correction: 'failed', praise: 'success' };
+const RETENTION_OUTCOMES = { kept: 'success', reverted: 'failed' };
+const RECEIPT_FIELDS = ['eligible', 'counted', 'weight', 'reason'];
+
+function turnTaskId(sessionId, turn) {
+ return `cursor:${sessionId || 'unknown'}:${turn ?? 'unknown'}`;
+}
+
+// The Hub has no `unknown`, and `unused` carries a soft negative there; a
+// partial reuse at tier none weighs nothing either way.
+const UNKNOWN_APPLIED_AS = 'partial';
+
+function immediateEvidenceOf({ checks, applied, reasonKind }) {
+ const check = checkEvidenceOf(checks);
+ const isKnown = applied !== UNKNOWN;
+ return {
+ tier: check && isKnown ? 'L1' : 'none',
+ applied: isKnown ? applied : UNKNOWN_APPLIED_AS,
+ ...(check && { check }),
+ aborted: reasonKind === 'aborted',
+ stage: 'immediate',
+ };
+}
+
+function immediateOutcomeOf(evidence, reasonKind) {
+ if (evidence.check) return evidence.check.passed ? 'success' : 'failed';
+ return outcomeOfReason(reasonKind) ?? 'failed';
+}
+
+function checkPhrase(check) {
+ if (!check) return 'no check ran';
+ const result = check.passed ? 'passed' : 'failed';
+ return `last ${check.kind} ${result}${check.fail_then_pass ? ' after failing earlier' : ''}`;
+}
+
+function reportOf({ assetId, taskId, outcome, reason, evidence }) {
+ return { asset_id: assetId, outcome, task_id: taskId, reason, evidence };
+}
+
+export function immediateReportOf({ asset, sessionId, turn, reasonKind, checks, applied }) {
+ const evidence = immediateEvidenceOf({ checks, applied, reasonKind });
+ return reportOf({
+ assetId: asset.assetId,
+ taskId: turnTaskId(sessionId, turn),
+ outcome: immediateOutcomeOf(evidence, reasonKind),
+ reason: `Injected by Evolver into Cursor turn ${turn}; applied: ${applied}; ${checkPhrase(evidence.check)}.`,
+ evidence,
+ });
+}
+
+export function reportedReuseOf(report) {
+ return { assetId: report.asset_id, applied: report.evidence.applied, taskId: report.task_id };
+}
+
+export function correctionReportOf(reuse, label) {
+ if (!LABEL_OUTCOMES[label]) return null;
+ return reportOf({
+ ...reuse,
+ taskId: `${reuse.taskId}:correction`,
+ outcome: LABEL_OUTCOMES[label],
+ reason: `The person's next prompt read as ${label} of the turn that reused this strategy.`,
+ evidence: { tier: 'L3', applied: reuse.applied, stage: 'correction' },
+ });
+}
+
+export function retentionReportOf(reuse, retentionClass) {
+ if (!RETENTION_OUTCOMES[retentionClass]) return null;
+ return reportOf({
+ ...reuse,
+ taskId: `${reuse.taskId}:retention`,
+ outcome: RETENTION_OUTCOMES[retentionClass],
+ reason: `By the next session the files the turn edited were ${retentionClass}.`,
+ evidence: { tier: 'L2', applied: reuse.applied, stage: 'retention' },
+ });
+}
+
+// A Proxy older than `terminal` says `hub 400` or `hub 422` for a request the
+// Hub refused as invalid. `hub 404` stays retryable: the Hub may not serve the
+// route yet.
+const REJECTED_BY_HUB = /^hub (?:400|422)\b/;
+
+function isRejection(data) {
+ return data?.terminal === true || REJECTED_BY_HUB.test(String(data?.reason ?? ''));
+}
+
+function isTaken(data) {
+ return data?.recorded !== false || data?.reason === 'duplicate_report';
+}
+
+function receiptOf(data) {
+ return Object.fromEntries(RECEIPT_FIELDS.filter((field) => data?.[field] !== undefined).map((field) => [field, data[field]]));
+}
+
+export function deliveryOf(result) {
+ if (result?.ok !== true) return { verdict: RETRY };
+ if (isRejection(result.data)) return { verdict: REJECTED, reason: String(result.data.reason ?? 'rejected'), status: result.data.status };
+ if (!isTaken(result.data)) return { verdict: RETRY };
+ return { verdict: SENT, receipt: receiptOf(result.data) };
+}
+
+async function postToHub(proxyFetch, report) {
+ try {
+ return deliveryOf(await proxyFetch('POST', '/asset/reuse-result', report));
+ } catch {
+ return { verdict: RETRY };
+ }
+}
+
+function isLocalRecordDue({ report, localAt }, delivery) {
+ return !localAt && delivery.verdict !== REJECTED && report.evidence.tier !== 'none';
+}
+
+async function recordedLocally(recordLocally, { report, sessionId }) {
+ return recordLocally({ assetId: report.asset_id, outcome: report.outcome, sessionId }).catch(() => false);
+}
+
+async function attemptOf(ledgers, entry) {
+ const delivery = await postToHub(ledgers.proxyFetch, entry.report);
+ const isLocal = isLocalRecordDue(entry, delivery) && (await recordedLocally(ledgers.recordLocally, entry));
+ return { ...delivery, isLocal };
+}
+
+export async function deliverPending(ledgers, outbox, now) {
+ let delivered = outbox;
+ for (const [key, entry] of Object.entries(outbox)) {
+ if (isSettled(entry)) continue;
+ delivered = withAttempt(delivered, key, await attemptOf(ledgers, entry), now);
+ }
+ return delivered;
+}
diff --git a/lib/session-distill.mjs b/lib/session-distill.mjs
new file mode 100644
index 0000000..f95df64
--- /dev/null
+++ b/lib/session-distill.mjs
@@ -0,0 +1,237 @@
+import { ownerIsGone } from './process-owner.mjs';
+import { withoutSecrets } from './redact.mjs';
+import { readState, statePath, updateState } from './state-file.mjs';
+
+// Distillation runs once a session, at its end, and outside the agent's conversation: each turn that
+// finished verified only leaves a candidate on disk, the latest one wins, and the session end (or, after a
+// session that was killed, the next session start) turns it into a Gene with one model call over a digest of
+// the turn. A candidate leaves the disk only once distilled or given up after MAX_ATTEMPTS failed tries.
+
+const PROMPT_MAX_CHARS = 6_000;
+const ANSWER_MAX_CHARS = 3_000;
+const COMMAND_MAX_CHARS = 300;
+const MAX_COMMANDS = 40;
+const LIST_FIELDS = ['strategy', 'validation', 'signals', 'preconditions'];
+const ARGUMENT_FIELDS = ['title', 'summary', ...LIST_FIELDS];
+const ITEM_NUMBER = /^\s*(?:\d+[.)]|[-*•])\s+/;
+const MAX_ATTEMPTS = 3;
+
+// A file of its own, so a dsh or Claude Code process never claims a Cursor candidate.
+export const pendingPath = () => statePath('cursor-distill-pending.json');
+
+/**
+ * Why a finished turn leaves no candidate, or null when it does. A turn that reused an injected strategy
+ * adds evidence to that asset through its reuse report rather than a near-copy of it; a chat reply without a
+ * single command has no method to keep.
+ */
+export function skipReason(reasonKind, record, { reusedAsset = false } = {}) {
+ if (reasonKind !== 'completed') return 'not_completed';
+ if (!record.answer) return 'no_answer';
+ if (record.checks.at(-1)?.failed) return 'failed_check';
+ if (record.commands.length === 0 && record.edits.length === 0) return 'no_tool_use';
+ if (reusedAsset) return 'asset_reused';
+ return null;
+}
+
+/**
+ * The model the session's turn ran on, as Cursor names it in the hook input: the distillation runs on it. Cursor
+ * sends an empty name when the chat runs on its default, which the CLI then picks as well.
+ */
+export function modelOf(hookInput) {
+ return { model: typeof hookInput?.model === 'string' && hookInput.model ? hookInput.model : 'default' };
+}
+
+// Keys and tokens are stripped before anything is written to disk or sent to a model or the Proxy.
+export function candidateOf({ prompt, record, model, turn, pid = null, now = Date.now() }) {
+ return {
+ prompt: withoutSecrets(String(prompt ?? '')).slice(0, PROMPT_MAX_CHARS),
+ commands: record.commands.slice(-MAX_COMMANDS).map((command) => withoutSecrets(command).slice(0, COMMAND_MAX_CHARS)),
+ paths: [...new Set(record.paths)].map((path) => withoutSecrets(path)),
+ checks: record.checks.map(({ command, failed }) => ({ command: withoutSecrets(command), failed })),
+ answer: withoutSecrets(record.answer).slice(0, ANSWER_MAX_CHARS),
+ model,
+ turn,
+ pid,
+ attempts: 0,
+ at: now,
+ };
+}
+
+export function rememberCandidate(sessionId, candidate) {
+ if (!sessionId) return;
+ updateState(pendingPath(), {}, (pending) => ({ ...pending, [sessionId]: candidate }));
+}
+
+export function dropCandidate(sessionId) {
+ if (!sessionId) return;
+ updateState(pendingPath(), {}, (pending) => {
+ if (!(sessionId in pending)) return undefined;
+ const { [sessionId]: dropped, ...rest } = pending;
+ return rest;
+ });
+}
+
+/** Whether this is still the session's candidate on disk: not dropped by a correction, nor replaced by a newer one. */
+export function isCurrentCandidate(sessionId, candidate) {
+ return readState(pendingPath(), {})[sessionId]?.at === candidate.at;
+}
+
+/** A session that ended keeps its candidate due, so a distillation that failed at its end is tried again. */
+export function closeCandidate(sessionId) {
+ if (!sessionId) return;
+ updateState(pendingPath(), {}, (pending) => (pending[sessionId]
+ ? { ...pending, [sessionId]: { ...pending[sessionId], closed: true } }
+ : undefined));
+}
+
+// A candidate is up for distillation again once its session is over and no live process is working on it.
+function leftover(candidate, isGone) {
+ const over = Boolean(candidate?.closed) || isGone(candidate?.pid);
+ return over && isUnclaimed(candidate, isGone);
+}
+
+function isUnclaimed(candidate, isGone) {
+ return !candidate?.claimedBy || isGone(candidate.claimedBy);
+}
+
+/** This session's own candidate, if it has one. Left on disk until settled. */
+export function dueCandidates({ sessionId }) {
+ const candidate = readState(pendingPath(), {})[sessionId];
+ return candidate ? [{ sessionId, candidate }] : [];
+}
+
+function claimWhere(isDue, pid) {
+ const claimed = [];
+ updateState(pendingPath(), {}, (pending) => {
+ const next = { ...pending };
+ for (const [sessionId, candidate] of Object.entries(pending)) {
+ if (!isDue(sessionId, candidate) || candidate.claimedBy === pid) continue;
+ next[sessionId] = { ...candidate, claimedBy: pid };
+ claimed.push({ sessionId, candidate: next[sessionId] });
+ }
+ return claimed.length > 0 ? next : undefined;
+ });
+ return claimed;
+}
+
+/**
+ * The candidates of sessions that are over — ended, or killed with their Cursor process — claimed for this
+ * process under the state lock, so two workers starting at once cannot distill the same one twice. An idle
+ * session whose Cursor process still runs keeps its candidate however long it waits.
+ */
+export function claimLeftoverCandidates({ isGone = ownerIsGone, pid = process.pid } = {}) {
+ return claimWhere((sessionId, candidate) => leftover(candidate, isGone), pid);
+}
+
+/** One session's candidate, claimed for a distillation asked for now, unless a live process is already on it. */
+export function claimSessionCandidate(sessionId, { isGone = ownerIsGone, pid = process.pid } = {}) {
+ return claimWhere((id, candidate) => id === sessionId && isUnclaimed(candidate, isGone), pid);
+}
+
+/**
+ * Removes a candidate once it was distilled, or counts a failed try and gives up after MAX_ATTEMPTS. A
+ * newer candidate the session wrote meanwhile is left alone.
+ */
+export function settleCandidate(sessionId, candidate, { sent }) {
+ updateState(pendingPath(), {}, (pending) => {
+ const current = pending[sessionId];
+ if (!current || current.at !== candidate.at) return undefined;
+ const attempts = (current.attempts ?? 0) + 1;
+ if (sent || attempts >= MAX_ATTEMPTS) {
+ const { [sessionId]: settled, ...rest } = pending;
+ return rest;
+ }
+ return { ...pending, [sessionId]: { ...current, attempts, claimedBy: null } };
+ });
+}
+
+export function distillPrompt(candidate, focus = '') {
+ const lines = (items, render) => items.map(render).join('\n') || '(none)';
+ const focusText = withoutSecrets(String(focus ?? ''));
+ return [
+ 'Below is the record of a task an agent has just finished and verified. Turn it into one reusable Gene:',
+ 'a method another agent could follow on a similar task, written for the kind of data or system involved,',
+ 'not for these files, names or values. Do not include the answer itself.',
+ ...(focusText ? [`Focus on: ${focusText}`] : []),
+ 'Answer with a JSON object only, with these fields:',
+ ' title: short capability name',
+ ' summary: what was solved and under which conditions',
+ ' strategy: ordered, reproducible steps',
+ ' validation: checks that proved the result',
+ ' signals: keywords of the problem this generalizes to',
+ ' preconditions: when the method applies',
+ '',
+ '## Task',
+ candidate.prompt,
+ '',
+ '## Commands run',
+ lines(candidate.commands, (command) => `- ${command}`),
+ '',
+ '## Files read or written',
+ lines(candidate.paths, (path) => `- ${path}`),
+ '',
+ '## Checks',
+ lines(candidate.checks, (check) => `- ${check.failed ? 'FAILED' : 'passed'}: ${check.command}`),
+ '',
+ '## Final answer',
+ candidate.answer,
+ ].join('\n');
+}
+
+// Models sometimes write a list field as one numbered paragraph ("1. … 2. …"), which the core reads as one step.
+function listOf(value) {
+ if (Array.isArray(value)) return value;
+ if (typeof value !== 'string') return value;
+ return value.split(/\n+|(?<=\.)\s+(?=\d+[.)]\s)/).map((item) => item.replace(ITEM_NUMBER, '').trim()).filter(Boolean);
+}
+
+export function argumentsOf(text) {
+ const match = String(text ?? '').match(/\{[\s\S]*\}/);
+ if (!match) return null;
+ try {
+ const parsed = JSON.parse(match[0]);
+ if (typeof parsed?.summary !== 'string' || !parsed.summary.trim()) return null;
+ for (const field of LIST_FIELDS) if (field in parsed) parsed[field] = listOf(parsed[field]);
+ return Object.fromEntries(ARGUMENT_FIELDS.filter((field) => parsed[field] !== undefined).map((field) => [field, parsed[field]]));
+ } catch {
+ return null;
+ }
+}
+
+export function distillBody(candidate, args, usage) {
+ return {
+ platform: 'cursor',
+ trigger: 'session_end',
+ user_prompt: candidate.prompt,
+ // The core rejects a Gene that repeats this answer's values (answer_leak).
+ final_answer: candidate.answer,
+ ...args,
+ execution: { trace: candidate.checks.map((check) => ({ command: check.command, exit: check.failed ? 1 : 0 })) },
+ persist: true,
+ publish: false,
+ ...(usage ? { distill_usage: usage } : {}),
+ };
+}
+
+/**
+ * Distill one candidate through `runModel(model, prompt, signal)`, the Cursor agent CLI run headless on the session's own model.
+ * `cancel` stops it and `isCurrent` is asked once more before anything is persisted, since the candidate may have
+ * been dropped while the model answered. Resolves to what happened; never throws.
+ */
+export async function distillCandidate({ proxyFetch, runModel, candidate, focus, timeoutMs, cancel, isCurrent = () => true }) {
+ if (!runModel || !candidate?.model?.model) return { status: 'no_model' };
+ const signal = AbortSignal.any([AbortSignal.timeout(timeoutMs), ...(cancel ? [cancel] : [])]);
+ try {
+ const { answer, usage } = await runModel(candidate.model.model, distillPrompt(candidate, focus), signal);
+ if (cancel?.aborted) return { status: 'dropped', usage };
+ const args = argumentsOf(answer);
+ if (!args) return { status: 'unparsed', usage };
+ if (!isCurrent()) return { status: 'dropped', usage };
+ const result = await proxyFetch('POST', '/conversation/distill', distillBody(candidate, args, usage), signal);
+ return result?.ok ? { status: 'sent', usage, data: result.data } : { status: 'failed', error: String(result?.error) };
+ } catch (error) {
+ if (cancel?.aborted) return { status: 'dropped' };
+ if (error?.code === 'ENOENT') return { status: 'no_model', error: 'no Cursor agent CLI to run the distillation' };
+ return { status: 'failed', error: String(error?.message ?? error) };
+ }
+}
diff --git a/lib/session-state.mjs b/lib/session-state.mjs
new file mode 100644
index 0000000..a5f056d
--- /dev/null
+++ b/lib/session-state.mjs
@@ -0,0 +1,94 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+import { OUTBOX_TTL_MS } from './report-outbox.mjs';
+import { hashedName, readState, stateDir, statePath, updateState } from './state-file.mjs';
+
+const SESSION_FILE = /^cursor-(?:session|turn|injected)-[0-9a-f]{16}\.jsonl?$/;
+
+const sessionPath = (sessionId) => statePath(hashedName('cursor-session', sessionId));
+
+const EMPTY_SESSION = { turn: 0, turnId: null, closed: true, settling: false, prompt: '', labelable: [], keepsLabelable: false };
+const SETTLE_POLL_MS = 50;
+
+export function readSession(sessionId) {
+ return { ...EMPTY_SESSION, ...readState(sessionPath(sessionId), {}) };
+}
+
+// A turn is open from its prompt until its stop; Cursor names it by generation_id, which a payload may lack.
+function isOpenTurn(session, turnId) {
+ return !session.closed && (!turnId || !session.turnId || turnId === session.turnId);
+}
+
+/** The open turn a tool or response hook of `turnId` belongs to, or null when it should record nothing. */
+export function recordingTurnOf(sessionId, turnId) {
+ const session = readSession(sessionId);
+ return isOpenTurn(session, turnId) ? session.turnId : undefined;
+}
+
+export function updateSession(sessionId, change) {
+ return updateState(sessionPath(sessionId), {}, (state) => change({ ...EMPTY_SESSION, ...state }));
+}
+
+/**
+ * Marks the session's open turn closed and settling, and returns it as it was, or null when `turnId` is not open
+ * (any turn when omitted). Closed, it records nothing more; settling, the next prompt waits for its reports and
+ * candidate before it reads the reuses to label or drops the candidate.
+ */
+export function closeOpenTurn(sessionId, turnId) {
+ let opened = null;
+ updateSession(sessionId, (session) => {
+ if (!isOpenTurn(session, turnId)) return undefined;
+ opened = session;
+ return { ...session, closed: true, settling: true };
+ });
+ return opened;
+}
+
+/**
+ * Ends the settling of `turnId` with the reuses its follow-up may label; a turn that reused nothing and keeps the
+ * earlier ones (a command turn) leaves them. A session that moved on is left alone.
+ */
+export function settleTurn(sessionId, turnId, reuses) {
+ updateSession(sessionId, (session) => {
+ if (!session.settling || session.turnId !== turnId) return undefined;
+ const labelable = reuses.length === 0 && session.keepsLabelable ? session.labelable : reuses;
+ return { ...session, settling: false, labelable };
+ });
+}
+
+/** Waits up to `timeoutMs` for the session's turn to be closed and settled; resolves the session as it then is. */
+export async function settledSession(sessionId, timeoutMs) {
+ const deadline = Date.now() + timeoutMs;
+ let session = readSession(sessionId);
+ while ((!session.closed || session.settling) && Date.now() < deadline) {
+ await new Promise((resolve) => setTimeout(resolve, SETTLE_POLL_MS));
+ session = readSession(sessionId);
+ }
+ return session;
+}
+
+/**
+ * Forgets what the ended session's turns left, but keeps its turn counter: a chat reopened after
+ * sessionEnd keeps its conversation_id, and a repeated `cursor::` would be taken by the Hub as a duplicate report.
+ * The file is pruned with the other session files once it is older than the outbox TTL.
+ */
+export function endSession(sessionId) {
+ updateSession(sessionId, (session) => ({ ...EMPTY_SESSION, turn: session.turn }));
+}
+
+export function pruneOldSessions(now = Date.now()) {
+ let names = [];
+ try {
+ names = fs.readdirSync(stateDir());
+ } catch {
+ return;
+ }
+ for (const name of names.filter((candidate) => SESSION_FILE.test(candidate))) {
+ const file = path.join(stateDir(), name);
+ try {
+ if (now - fs.statSync(file).mtimeMs > OUTBOX_TTL_MS) fs.rmSync(file, { force: true });
+ } catch {
+ }
+ }
+}
diff --git a/lib/signals.mjs b/lib/signals.mjs
new file mode 100644
index 0000000..867803e
--- /dev/null
+++ b/lib/signals.mjs
@@ -0,0 +1,47 @@
+export 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.'],
+};
+
+const CODE_LINE_PREFIXES = ['//', '#', '*', '{', '[', '}', ']', '/*'];
+// A keyword inside a statement describes what the code does, not what happened:
+// `expect(order).toBe(paid)` is a passing test being written, not a test failure.
+const CODE_STATEMENT = /^(?:[A-Za-z_$][\w$.]*\s*\(|(?:import|export|const|let|var|function|class|return|if|for|while|switch|throw|await|async)\b)/;
+
+function looksLikeCode(trimmedLine) {
+ return (
+ CODE_LINE_PREFIXES.some((prefix) => trimmedLine.startsWith(prefix)) ||
+ CODE_STATEMENT.test(trimmedLine) ||
+ /[;{}]$/.test(trimmedLine)
+ );
+}
+
+function proseOf(text) {
+ return text
+ .split('\n')
+ .filter((line) => {
+ const trimmed = line.trim();
+ return trimmed.length > 0 && !looksLikeCode(trimmed);
+ })
+ .join('\n')
+ .toLowerCase();
+}
+
+export function detectSignals(text) {
+ if (typeof text !== 'string' || text.length === 0) return [];
+
+ const prose = proseOf(text);
+ if (!prose) return [];
+
+ const found = new Set();
+ for (const [category, phrases] of Object.entries(SIGNAL_KEYWORDS)) {
+ if (phrases.some((phrase) => prose.includes(phrase))) found.add(category);
+ }
+ return [...found].sort();
+}
+
diff --git a/lib/state-file.mjs b/lib/state-file.mjs
new file mode 100644
index 0000000..177afb1
--- /dev/null
+++ b/lib/state-file.mjs
@@ -0,0 +1,190 @@
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+import { ownerIsGone } from './process-owner.mjs';
+
+const CLAIM_STALE_MS = 60 * 1000;
+const LOCK_WAIT_MS = 2_000;
+const LOCK_RETRY_MS = 20;
+
+export function stateDir() {
+ return path.join(os.homedir(), '.evolver', 'state');
+}
+
+export function statePath(name) {
+ return path.join(stateDir(), name);
+}
+
+export function hashedName(prefix, key, extension = 'json') {
+ return `${prefix}-${crypto.createHash('sha256').update(String(key)).digest('hex').slice(0, 16)}.${extension}`;
+}
+
+export function readState(target, fallback) {
+ try {
+ const parsed = JSON.parse(fs.readFileSync(target, 'utf8'));
+ return parsed && typeof parsed === 'object' && Array.isArray(parsed) === Array.isArray(fallback) ? parsed : fallback;
+ } catch {
+ return fallback;
+ }
+}
+
+export function writeState(target, value) {
+ try {
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
+ const temporary = `${target}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
+ fs.writeFileSync(temporary, JSON.stringify(value), { mode: 0o600, flag: 'wx' });
+ try {
+ fs.renameSync(temporary, target);
+ } catch (error) {
+ if (!['EEXIST', 'EPERM'].includes(error?.code)) throw error;
+ fs.rmSync(target, { force: true });
+ fs.renameSync(temporary, target);
+ }
+ } catch {
+ }
+}
+
+export function removeState(target) {
+ try {
+ fs.rmSync(target, { force: true });
+ } catch {
+ }
+}
+
+export function appendStateLine(target, value) {
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
+ fs.appendFileSync(target, `${JSON.stringify(value)}\n`, { mode: 0o600 });
+}
+
+function linesOf(file) {
+ return fs.readFileSync(file, 'utf8').split('\n').flatMap((line) => {
+ try {
+ return line ? [JSON.parse(line)] : [];
+ } catch {
+ return [];
+ }
+ });
+}
+
+function staleClaimsOf(target, now) {
+ const prefix = `${path.basename(target)}.`;
+ try {
+ return fs.readdirSync(path.dirname(target))
+ .filter((name) => name.startsWith(prefix) && name.endsWith('.taking'))
+ .map((name) => path.join(path.dirname(target), name))
+ .filter((file) => now - fs.statSync(file).mtimeMs > CLAIM_STALE_MS);
+ } catch {
+ return [];
+ }
+}
+
+function claimed(source, target) {
+ const claim = `${target}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.taking`;
+ try {
+ fs.renameSync(source, claim);
+ fs.utimesSync(claim, new Date(), new Date());
+ return claim;
+ } catch {
+ return null;
+ }
+}
+
+// Lines move into a claim file that is deleted only on commit, so a process
+// that dies between taking and persisting them leaves the claim behind for
+// the next taker to pick up once it is stale.
+export function takeStateLines(target, now = Date.now()) {
+ const claims = [...staleClaimsOf(target, now), target].map((source) => claimed(source, target)).filter(Boolean);
+ return {
+ lines: claims.flatMap(linesOf),
+ commit: () => {
+ for (const claim of claims) fs.rmSync(claim, { force: true });
+ },
+ };
+}
+
+// Atomics.wait is the only sleep that holds a synchronous read-modify-write together.
+function pause(ms) {
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
+}
+
+function contentOf(file) {
+ try {
+ return fs.readFileSync(file, 'utf8');
+ } catch {
+ return null;
+ }
+}
+
+const ownerPidOf = (token) => Number(token.split(':')[0]);
+
+// Another process may reclaim the same dead lock and a third take a fresh one before this removal happens, so the
+// lock is first moved aside, and only what was inspected as dead is deleted; anything else is put back.
+function reclaim(lockPath, deadToken) {
+ const aside = `${lockPath}.${crypto.randomBytes(6).toString('hex')}.reclaimed`;
+ try {
+ fs.renameSync(lockPath, aside);
+ } catch {
+ return;
+ }
+ if (contentOf(aside) !== deadToken) {
+ try {
+ fs.linkSync(aside, lockPath);
+ } catch {
+ }
+ }
+ removeState(aside);
+}
+
+function acquire(lockPath, deadline) {
+ const token = `${process.pid}:${crypto.randomBytes(8).toString('hex')}`;
+ for (;;) {
+ try {
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
+ fs.writeFileSync(lockPath, token, { mode: 0o600, flag: 'wx' });
+ return token;
+ } catch (error) {
+ if (error?.code !== 'EEXIST') return null;
+ const held = contentOf(lockPath);
+ if (held !== null && ownerIsGone(ownerPidOf(held))) reclaim(lockPath, held);
+ else if (Date.now() >= deadline) return null;
+ else pause(LOCK_RETRY_MS);
+ }
+ }
+}
+
+function release(lockPath, token) {
+ if (contentOf(lockPath) === token) removeState(lockPath);
+}
+
+/**
+ * Reads a state file, applies `mutate` and writes the result back while holding an exclusive lock, so hooks,
+ * background workers and other hosts sharing `~/.evolver/state` cannot overwrite each other's entries. A lock
+ * whose owner died is taken over; one that cannot be had within LOCK_WAIT_MS is given up on and the update runs
+ * unguarded, which never blocks a hook.
+ */
+export function updateState(target, fallback, mutate) {
+ const lockPath = `${target}.lock`;
+ const token = acquire(lockPath, Date.now() + LOCK_WAIT_MS);
+ try {
+ const next = mutate(readState(target, fallback));
+ if (next !== undefined) writeState(target, next);
+ return next;
+ } finally {
+ if (token) release(lockPath, token);
+ }
+}
+
+/** Runs `work` while this process alone holds the named lock; resolves false at once when another live process does. */
+export async function withLock(name, work) {
+ const lockPath = statePath(`${name}.lock`);
+ const token = acquire(lockPath, Date.now());
+ if (!token) return false;
+ try {
+ await work();
+ return true;
+ } finally {
+ release(lockPath, token);
+ }
+}
diff --git a/lib/status-report.mjs b/lib/status-report.mjs
new file mode 100644
index 0000000..3eb72c8
--- /dev/null
+++ b/lib/status-report.mjs
@@ -0,0 +1,98 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+import { engineNoticeOf } from './engine-notice.mjs';
+import { engineDir } from './engine-runtime.mjs';
+import { readEngineState } from './engine-state.mjs';
+import { MIN_EVOLVER_VERSION } from './engine-version.mjs';
+import { describeProxyEndpoint } from './proxy.mjs';
+import { readRecallLog } from './recall-log.mjs';
+import { outboxPath } from './report-queue.mjs';
+import { pendingPath } from './session-distill.mjs';
+import { readState } from './state-file.mjs';
+
+const RECENT_REPORTS = 5;
+
+const line = (mark, label, value) => `${mark} ${label}: ${value}`;
+const at = (ms) => (ms ? new Date(ms).toISOString() : '(never)');
+
+function proxyLines(proxy) {
+ const endpoint = describeProxyEndpoint();
+ if (!proxy.ok) return [line('MISSING', 'Proxy', proxy.error)];
+ const version = endpoint.running_version ? `, Evolver ${endpoint.running_version}` : '';
+ return [line('OK', 'Proxy', `${proxy.data?.status ?? 'answering'} at ${endpoint.url}${version}`)];
+}
+
+function engineLines(outcome) {
+ const lines = [line('INFO', 'Required Evolver', `${MIN_EVOLVER_VERSION} or newer`), line('INFO', 'Plugin-managed install', engineDir())];
+ if (!outcome) return [...lines, line('INFO', 'Engine setup', 'not run yet; it runs in the background at the next session start')];
+ const mark = outcome.failures > 0 ? 'FAIL' : 'OK';
+ lines.push(line(mark, 'Engine setup', `${outcome.status} at ${at(outcome.at)}${outcome.version ? `, Evolver ${outcome.version} (${outcome.source})` : ''}`));
+ const notice = engineNoticeOf(outcome);
+ return notice ? [...lines, ` ${notice.replace(/^\[Evolver\] /, '')}`] : lines;
+}
+
+function recallLines({ last, counts }) {
+ if (!last) return [line('INFO', 'Recall', 'no prompt recalled yet')];
+ const tally = Object.entries(counts ?? {}).map(([status, count]) => `${status}=${count}`).join(' ');
+ return [line('INFO', 'Recall', `last ${last.status}${last.asset_id ? ` ${last.asset_id}` : ''} at ${last.at}; ${tally}`)];
+}
+
+function receiptPhrase(receipt = {}) {
+ const parts = ['eligible', 'counted', 'weight'].filter((field) => receipt[field] !== undefined).map((field) => `${field}=${receipt[field]}`);
+ if (receipt.reason) parts.push(`reason=${receipt.reason}`);
+ return parts.length > 0 ? parts.join(' ') : 'recorded';
+}
+
+function reportLines(outbox) {
+ const entries = Object.values(outbox);
+ const pending = entries.filter((entry) => !entry.sentAt && !entry.rejectedAt);
+ const delivered = entries.filter((entry) => entry.sentAt).sort((a, b) => b.sentAt - a.sentAt).slice(0, RECENT_REPORTS);
+ const rejected = entries.filter((entry) => entry.rejectedAt).sort((a, b) => b.rejectedAt - a.rejectedAt).slice(0, RECENT_REPORTS);
+ return [
+ line('INFO', 'Reuse reports', `${pending.length} waiting, ${delivered.length ? 'recent deliveries below' : 'none delivered yet'}`),
+ ...delivered.map((entry) => ` sent ${at(entry.sentAt)} ${entry.report.task_id} ${entry.report.asset_id} tier=${entry.report.evidence.tier} ${receiptPhrase(entry.receipt)}`),
+ ...rejected.map((entry) => {
+ const { reason, status } = entry.rejection ?? {};
+ return ` REJECTED ${at(entry.rejectedAt)} ${entry.report.task_id} ${entry.report.asset_id} reason=${reason}${status ? ` status=${status}` : ''}`;
+ }),
+ ];
+}
+
+function candidateLines(pending) {
+ const sessions = Object.entries(pending);
+ if (sessions.length === 0) return [line('INFO', 'Distillation', 'no candidate waiting')];
+ return [
+ line('INFO', 'Distillation', `${sessions.length} candidate(s) waiting`),
+ ...sessions.map(([sessionId, candidate]) => ` ${sessionId} turn ${candidate.turn} attempts=${candidate.attempts ?? 0}${candidate.closed ? ' closed' : ''}`),
+ ];
+}
+
+function claimLines() {
+ const claimUrlPath = path.join(os.homedir(), '.evomap', 'claim_url');
+ try {
+ const url = fs.readFileSync(claimUrlPath, 'utf8').trim();
+ return [
+ 'INFO Network: node registered but NOT YET CLAIMED.',
+ ` To connect, sign in to https://evomap.ai and open this link: ${url}`,
+ " That's the only step — no id or secret to find. Local memory works regardless.",
+ ];
+ } catch {
+ return ['INFO Network: local memory works with zero config; no pending claim link.'];
+ }
+}
+
+export async function statusReport(proxyFetch) {
+ const proxy = await proxyFetch('GET', '/proxy/status');
+ return [
+ 'Evolver Cursor plugin status',
+ line('INFO', 'Node.js', process.version),
+ ...proxyLines(proxy),
+ ...engineLines(readEngineState()),
+ ...recallLines(readRecallLog()),
+ ...reportLines(readState(outboxPath(), {})),
+ ...candidateLines(readState(pendingPath(), {})),
+ ...claimLines(),
+ ].join('\n');
+}
diff --git a/lib/turn-end.mjs b/lib/turn-end.mjs
new file mode 100644
index 0000000..c4d23ec
--- /dev/null
+++ b/lib/turn-end.mjs
@@ -0,0 +1,63 @@
+import { appliedVerdictOf } from './applied.mjs';
+import { takeUnreported } from './injected-assets.mjs';
+import { queueReports } from './report-queue.mjs';
+import { rememberForRetention } from './retention-pending.mjs';
+import { immediateReportOf, reportedReuseOf } from './reuse.mjs';
+import { candidateOf, rememberCandidate, skipReason } from './session-distill.mjs';
+import { closeOpenTurn, settleTurn } from './session-state.mjs';
+import { takeTurnRecord } from './turn-record.mjs';
+import { outcomeOfReason } from './turn-outcomes.mjs';
+
+const EMPTY_RECORD = { checks: [], commands: [], paths: [], edits: [], answer: '' };
+
+function reportsOf(assets, { sessionId, turn, reasonKind, record }) {
+ return assets.map((asset) => immediateReportOf({
+ asset,
+ sessionId,
+ turn: turn ?? asset.turn,
+ reasonKind,
+ checks: record.checks,
+ applied: appliedVerdictOf({ strategyText: asset.strategyText, ...record }).verdict,
+ }));
+}
+
+export function abortedReports(assets) {
+ return assets.map((asset) => ({
+ report: reportsOf([asset], { sessionId: asset.sessionId, reasonKind: 'aborted', record: EMPTY_RECORD })[0],
+ sessionId: asset.sessionId,
+ }));
+}
+
+function rememberTurnCandidate({ sessionId, session, reasonKind, record, reusedAsset, model, ownerPid }) {
+ if (skipReason(reasonKind, record, { reusedAsset })) return;
+ rememberCandidate(sessionId, candidateOf({ prompt: session.prompt, record, model, turn: session.turn, pid: ownerPid() }));
+}
+
+/**
+ * Every injected strategy is reported when its turn ends, with the turn's own evidence; the turn itself becomes
+ * the session's distillation candidate when it finished verified without reusing one. Returns the reuses the
+ * next prompt may label, and whether anything was queued. `ownerPid` is only asked when a candidate is written.
+ */
+function closeTurn({ sessionId, session, reasonKind, model, projectDir, ownerPid }) {
+ const record = takeTurnRecord(sessionId, session.turnId);
+ if (!outcomeOfReason(reasonKind)) return { reuses: [], queued: false };
+ const reports = reportsOf(takeUnreported(sessionId, session.turn), { sessionId, turn: session.turn, reasonKind, record });
+ const reuses = reports.map(reportedReuseOf);
+ queueReports(reports.map((report) => ({ report, sessionId })));
+ rememberForRetention({ sessionId, projectDir, reuses, editedBefore: record.editedBefore });
+ const reusedAsset = reuses.some((reuse) => reuse.applied === 'used');
+ rememberTurnCandidate({ sessionId, session, reasonKind, record, reusedAsset, model, ownerPid });
+ return { reuses, queued: reports.length > 0 };
+}
+
+/**
+ * Closes the session's open turn (`turnId`, or any when omitted) as `reasonKind`, reports it, and settles it with
+ * its reuses. Returns them, and whether anything was queued; null when no such turn was open.
+ */
+export function endOpenTurn({ sessionId, turnId, reasonKind, model = null, projectDir, ownerPid = () => null }) {
+ const session = closeOpenTurn(sessionId, turnId);
+ if (!session) return null;
+ const ended = closeTurn({ sessionId, session, reasonKind, model, projectDir, ownerPid });
+ settleTurn(sessionId, session.turnId, ended.reuses);
+ return ended;
+}
diff --git a/lib/turn-outcomes.mjs b/lib/turn-outcomes.mjs
new file mode 100644
index 0000000..9509407
--- /dev/null
+++ b/lib/turn-outcomes.mjs
@@ -0,0 +1,11 @@
+const TURN_OUTCOMES = {
+ completed: 'success',
+ 'max-tokens': 'failed',
+ aborted: 'failed',
+ blocked: 'failed',
+ error: 'failed',
+};
+
+export function outcomeOfReason(reasonKind) {
+ return TURN_OUTCOMES[reasonKind] ?? null;
+}
diff --git a/lib/turn-record.mjs b/lib/turn-record.mjs
new file mode 100644
index 0000000..240582c
--- /dev/null
+++ b/lib/turn-record.mjs
@@ -0,0 +1,132 @@
+import path from 'node:path';
+
+import { contentHashOf } from './retention-pending.mjs';
+import { appendStateLine, hashedName, removeState, statePath, takeStateLines } from './state-file.mjs';
+import { isVerificationCommand, shellCheckFailed } from './verification.mjs';
+
+export const SHELL_TOOL = 'Shell';
+export const EDIT_TOOLS = new Set(['Write', 'Delete']);
+
+const TURN_EDITED_MAX_CHARS = 200_000;
+const CHECK_COMMAND_MAX_CHARS = 200;
+const PATH_KEYS = ['path', 'file_path'];
+const NOT_RUN_FAILURES = new Set(['permission_denied']);
+// What Cursor's Shell tool says for a result that is neither an exit, a timeout nor a refusal, such as a command
+// moved to the background before it finished: no verdict yet.
+const UNSETTLED_SHELL_ERROR = 'Unknown error';
+
+// One file a turn, so the next prompt never clears notes the previous turn's stop has yet to take.
+const recordPath = (sessionId, turnId) => statePath(hashedName('cursor-turn', `${sessionId}\n${turnId ?? ''}`, 'jsonl'));
+
+function freshTurnRecord() {
+ return { checks: [], commands: [], paths: [], edits: [], editedBefore: new Map(), answer: '' };
+}
+
+function editedPathOf(input) {
+ return typeof input?.file_path === 'string' && input.file_path ? input.file_path : null;
+}
+
+export function noteFileBeforeEdit(sessionId, turnId, projectDir, { tool_name: name, tool_input: input }) {
+ const edited = EDIT_TOOLS.has(name) ? editedPathOf(input) : null;
+ if (!edited) return;
+ const file = path.resolve(projectDir, edited);
+ appendStateLine(recordPath(sessionId, turnId), { kind: 'before', path: file, hash: contentHashOf(file) });
+}
+
+function parsedOutput(text) {
+ try {
+ const parsed = JSON.parse(text);
+ return parsed && typeof parsed === 'object' ? parsed : {};
+ } catch {
+ return { output: String(text ?? '') };
+ }
+}
+
+function checkOf(command, result) {
+ if (!isVerificationCommand(command)) return {};
+ return { check: { command: command.slice(0, CHECK_COMMAND_MAX_CHARS), failed: shellCheckFailed(result) } };
+}
+
+function baseNote(input) {
+ return {
+ kind: 'tool',
+ ...(typeof input?.command === 'string' && { command: input.command }),
+ paths: PATH_KEYS.map((key) => input?.[key]).filter((value) => typeof value === 'string'),
+ };
+}
+
+/** A tool call that ran, as postToolUse hands it over; Shell output is `{"output","exitCode"}` as JSON text. */
+export function noteOf({ tool_name: name, tool_input: input, tool_output: output }) {
+ const note = baseNote(input);
+ if (name === SHELL_TOOL && note.command) return { ...note, ...checkOf(note.command, parsedOutput(output)) };
+ return note;
+}
+
+/** An applied edit, as afterFileEdit hands it over: only the text it wrote in, not the whole file. */
+export function editNoteOf({ file_path: file, edits }) {
+ const written = (Array.isArray(edits) ? edits : []).map((edit) => edit?.new_string).filter((text) => typeof text === 'string' && text);
+ return { kind: 'edit', paths: typeof file === 'string' && file ? [file] : [], content: written.join('\n') };
+}
+
+/**
+ * A tool call that failed, as postToolUseFailure hands it over: a Shell command that exited non-zero or timed
+ * out failed its check; one the person interrupted or that is still running has no verdict, and one that was
+ * denied never ran.
+ */
+export function failureNoteOf({ tool_name: name, tool_input: input, error_message: message, failure_type: type, is_interrupt: interrupted }) {
+ if (NOT_RUN_FAILURES.has(type)) return null;
+ const note = baseNote(input);
+ if (name !== SHELL_TOOL || !note.command || interrupted || message === UNSETTLED_SHELL_ERROR) return note;
+ return { ...note, ...checkOf(note.command, { exitCode: 1, output: message }) };
+}
+
+export function noteToolResult(sessionId, turnId, toolUse) {
+ appendStateLine(recordPath(sessionId, turnId), noteOf(toolUse));
+}
+
+export function noteToolFailure(sessionId, turnId, toolUse) {
+ const note = failureNoteOf(toolUse);
+ if (note) appendStateLine(recordPath(sessionId, turnId), note);
+}
+
+export function noteFileEdit(sessionId, turnId, edit) {
+ appendStateLine(recordPath(sessionId, turnId), editNoteOf(edit));
+}
+
+export function noteAnswer(sessionId, turnId, text) {
+ if (typeof text === 'string' && text.trim()) appendStateLine(recordPath(sessionId, turnId), { kind: 'answer', text: text.trim() });
+}
+
+function addNote(record, note) {
+ if (note.kind === 'before') {
+ if (!record.editedBefore.has(note.path)) record.editedBefore.set(note.path, note.hash);
+ return;
+ }
+ if (note.kind === 'answer') {
+ record.answer = note.text;
+ return;
+ }
+ if (typeof note.command === 'string') record.commands.push(note.command);
+ if (note.check) record.checks.push(note.check);
+ record.paths.push(...(note.paths ?? []));
+ const editedChars = record.edits.reduce((total, edit) => total + edit.length, 0);
+ const kept = String(note.content ?? '').slice(0, TURN_EDITED_MAX_CHARS - editedChars);
+ if (kept) record.edits.push(kept);
+}
+
+export function turnRecordOf(notes) {
+ const record = freshTurnRecord();
+ for (const note of notes) addNote(record, note);
+ return record;
+}
+
+/** What the turn did, as its tool and response hooks noted it; the notes leave the disk once taken. */
+export function takeTurnRecord(sessionId, turnId) {
+ const { lines, commit } = takeStateLines(recordPath(sessionId, turnId));
+ commit();
+ return turnRecordOf(lines);
+}
+
+export function forgetTurnRecord(sessionId, turnId) {
+ removeState(recordPath(sessionId, turnId));
+}
diff --git a/lib/verification.mjs b/lib/verification.mjs
new file mode 100644
index 0000000..fb48d51
--- /dev/null
+++ b/lib/verification.mjs
@@ -0,0 +1,34 @@
+import { detectSignals } from './signals.mjs';
+
+const VERIFICATION_COMMANDS = [
+ /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:test|build|lint|typecheck|type-check|check)\b/,
+ /\bnode\s+--test\b/,
+ /\b(?:jest|vitest|mocha|pytest|rspec|phpunit|tsc|eslint|ruff|mypy)\b/,
+ /\bgo\s+(?:test|build|vet)\b/,
+ /\bcargo\s+(?:test|build|check|clippy)\b/,
+ /\bdotnet\s+(?:test|build)\b/,
+ /\b(?:mvn|gradle|gradlew)\b/,
+ /\bmake\b/,
+];
+
+const FAILURE_SIGNALS = new Set(['test_failure', 'log_error']);
+
+// Summaries of a passing run still name failures by count ("0 failed",
+// "failures: 0"); only those counts are dropped, so "1 failed, 0 errors"
+// still reads as a failure.
+const ZERO_COUNT = /\b0\s+(?:failed|failures?|errors?)\b|\b(?:failed|failures?|errors?)\s*[:=]\s*0\b/gi;
+
+export function isVerificationCommand(command) {
+ return typeof command === 'string' && VERIFICATION_COMMANDS.some((pattern) => pattern.test(command));
+}
+
+export function verificationFailed(output) {
+ const evidence = String(output ?? '').replace(ZERO_COUNT, '');
+ return detectSignals(evidence).some((signal) => FAILURE_SIGNALS.has(signal));
+}
+
+// Cursor hands the Shell tool's exit code to postToolUse; a non-zero one decides, like dsh's isError. A zero
+// one does not clear the run, since a failing suite piped through `tail` still exits zero.
+export function shellCheckFailed({ exitCode, output }) {
+ return (Number.isInteger(exitCode) && exitCode !== 0) || verificationFailed(output);
+}
diff --git a/mcp.json b/mcp.json
deleted file mode 100644
index de6abec..0000000
--- a/mcp.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "mcpServers": {
- "evolver-proxy": {
- "command": "node",
- "args": ["${CURSOR_PLUGIN_ROOT}/mcp/evolver-proxy.mjs"],
- "env": {
- "EVOMAP_PROXY_PORT": "${EVOMAP_PROXY_PORT}",
- "EVOMAP_HUB_URL": "${EVOMAP_HUB_URL}",
- "EVOMAP_NODE_ID": "${EVOMAP_NODE_ID}",
- "EVOLVE_STRATEGY": "${EVOLVE_STRATEGY}"
- }
- }
- }
-}
diff --git a/mcp/evolver-proxy.mjs b/mcp/evolver-proxy.mjs
deleted file mode 100644
index a83f77e..0000000
--- a/mcp/evolver-proxy.mjs
+++ /dev/null
@@ -1,384 +0,0 @@
-#!/usr/bin/env node
-// SPDX-License-Identifier: MIT
-// Copyright (c) 2026 EvoMap
-/**
- * Evolver Proxy MCP bridge (stdio, zero dependencies).
- *
- * Exposes the EvoMap local Proxy mailbox — recipes, genes, capsules, status — as MCP
- * tools so Claude can search/reuse/publish evolution assets natively.
- *
- * Transport: newline-delimited JSON-RPC 2.0 over stdin/stdout (MCP stdio).
- * All diagnostics go to stderr; stdout carries protocol traffic ONLY.
- *
- * The Proxy is a separate local process started by the @evomap/evolver CLI.
- * This bridge never spawns it; when it is down, tools return a helpful error.
- */
-
-import { readFileSync } from 'node:fs';
-import { homedir } from 'node:os';
-import { join } from 'node:path';
-import { createInterface } from 'node:readline';
-
-const SERVER = { name: 'evolver-proxy', version: '2.1.0' };
-const DEFAULT_PROTOCOL = '2025-06-18';
-
-function log(...a) { process.stderr.write('[evolver-proxy-mcp] ' + a.join(' ') + '\n'); }
-
-function defaultProxyUrl() {
- const raw = process.env.EVOMAP_PROXY_PORT;
- const port =
- typeof raw === 'string' && raw.length > 0 && !raw.includes('${')
- ? raw
- : '19820';
- return `http://127.0.0.1:${port}`;
-}
-
-function isLoopbackHost(hostname) {
- const value = String(hostname || '').toLowerCase();
- return (
- value === 'localhost' ||
- value === '127.0.0.1' ||
- value === '::1' ||
- value.endsWith('.localhost')
- );
-}
-
-function normalizeLocalProxyUrl(raw) {
- try {
- const parsed = new URL(String(raw));
- if (!['http:', 'https:'].includes(parsed.protocol)) return null;
- if (!isLoopbackHost(parsed.hostname)) return null;
- parsed.pathname = parsed.pathname.replace(/\/+$/, '');
- parsed.hash = '';
- return parsed.toString().replace(/\/+$/, '');
- } catch {
- return null;
- }
-}
-
-/**
- * Resolve the live Proxy connection. ~/.evolver/settings.json is authoritative:
- * the running Proxy writes both its url and a per-instance auth token there.
- * Recent Proxy builds reject unauthenticated local requests with 401, so we
- * send `Authorization: Bearer `. Re-read every call — the token rotates
- * whenever the Proxy restarts. Never log or echo the token.
- */
-function readProxySettings() {
- let url = null, token = null;
- try {
- const s = JSON.parse(readFileSync(join(homedir(), '.evolver', 'settings.json'), 'utf8'));
- if (s?.proxy?.url) url = normalizeLocalProxyUrl(s.proxy.url);
- if (s?.proxy?.token) token = String(s.proxy.token);
- } catch { /* not running / unreadable — fall through */ }
- if (!url) {
- url = defaultProxyUrl();
- token = null;
- }
- return { url, token };
-}
-
-async function proxyFetch(method, path, body) {
- const { url: base, token } = readProxySettings();
- const ctrl = new AbortController();
- const timer = setTimeout(() => ctrl.abort(), 8000);
- try {
- const headers = {};
- if (body) headers['Content-Type'] = 'application/json';
- if (token) headers['Authorization'] = `Bearer ${token}`;
- const res = await fetch(base + path, {
- method,
- headers: Object.keys(headers).length ? headers : undefined,
- body: body ? JSON.stringify(body) : undefined,
- signal: ctrl.signal,
- });
- const text = await res.text();
- let data; try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; }
- if (!res.ok) {
- // Make auth/connection failures actionable. Never echo the token.
- let hint = '';
- if ([401, 403].includes(res.status)) {
- hint = token
- ? ' The Proxy token in ~/.evolver/settings.json looks stale (the Proxy mints a fresh token on restart). Restart this Cursor session so the bridge re-reads it, or run /status.'
- : ` No Proxy token found in ~/.evolver/settings.json and the request was rejected — another process may be using ${base}. Start the Proxy (run \`evolver\` once in a git repo) or set EVOMAP_PROXY_PORT, then run /status.`;
- } else if (res.status === 404) {
- hint = ` Endpoint not found at ${base} — it may not be the Evolver Proxy. Confirm with /status.`;
- }
- return { ok: false, error: `Proxy at ${base} returned HTTP ${res.status}: ${typeof data === 'object' ? JSON.stringify(data) : text}.${hint}` };
- }
- return { ok: true, data };
- } catch (e) {
- const hint = `Evolver Proxy not reachable at ${base}. Start it by running \`evolver\` once inside a git repo (the CLI launches the Proxy), or run /status. Set EVOMAP_PROXY_PORT if you use a non-default port.`;
- return { ok: false, error: `${e.name === 'AbortError' ? 'Proxy request timed out' : 'Proxy connection failed: ' + e.message}. ${hint}` };
- } finally {
- clearTimeout(timer);
- }
-}
-
-// ---- Tool registry -------------------------------------------------------
-
-const TOOLS = [
- {
- name: 'evolver_status',
- description: 'Get the EvoMap Proxy status: running state, node_id, pending inbound/outbound message counts, and last Hub sync time. Use this first to confirm the Proxy is up.',
- inputSchema: { type: 'object', properties: {}, additionalProperties: false },
- handler: () => proxyFetch('GET', '/proxy/status'),
- },
- {
- name: 'evolver_recipe_search',
- description: 'Default first step: search Hub Recipes (ordered Gene/Capsule DNA) via the local Evolver Proxy. On a hit, call evolver_recipe_express. If nothing matches, fall back to evolver_search_assets. Omit q to list published Recipes.',
- inputSchema: {
- type: 'object',
- properties: {
- q: { type: 'string', description: 'Free-text Recipe query. Omit to list published Recipes.' },
- query: { type: 'string', description: 'Alias of q.' },
- text: { type: 'string', description: 'Alias of q.' },
- limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 },
- cursor: { type: 'string' },
- sort: { type: 'string' },
- },
- additionalProperties: false,
- },
- handler: (a) => {
- const q = [a.q, a.query, a.text].find((value) => typeof value === 'string' && value.trim().length > 0);
- return proxyFetch('POST', '/recipe/search', {
- ...(q ? { q } : {}),
- ...(typeof a.limit === 'number' ? { limit: a.limit } : {}),
- ...(typeof a.cursor === 'string' && a.cursor.trim() ? { cursor: a.cursor } : {}),
- ...(typeof a.sort === 'string' && a.sort.trim() ? { sort: a.sort } : {}),
- });
- },
- },
- {
- name: 'evolver_recipe_express',
- description: 'Express a Hub Recipe by id. Forwards only to Proxy POST /recipe/express (Hub POST /a2a/recipe/{id}/express). Hub unfolds Gene then Capsule steps; do not parse recipe JSON locally. Prefer this after evolver_recipe_search.',
- inputSchema: {
- type: 'object',
- required: ['recipeId'],
- properties: {
- recipeId: { type: 'string', description: 'Hub Recipe id from evolver_recipe_search.' },
- inputPayload: { type: 'object', description: 'Optional JSON object forwarded as input_payload.' },
- },
- additionalProperties: false,
- },
- handler: (a) => {
- const recipeId = typeof a.recipeId === 'string' ? a.recipeId.trim() : '';
- if (!recipeId) {
- return Promise.resolve({ ok: false, error: 'Provide a `recipeId` from evolver_recipe_search.' });
- }
- const inputPayload = a.inputPayload && typeof a.inputPayload === 'object' && !Array.isArray(a.inputPayload)
- ? a.inputPayload
- : undefined;
- return proxyFetch('POST', '/recipe/express', {
- recipe_id: recipeId,
- ...(inputPayload ? { input_payload: inputPayload } : {}),
- });
- },
- },
- {
- name: 'evolver_search_assets',
- description: 'Fallback: search the EvoMap network for Genes and Capsules when evolver_recipe_search has no Recipe hit. Pass `query` and/or `signals`. Real networked reuse should go through evolver_recipe_express so Hub unfolds the steps.',
- inputSchema: {
- type: 'object',
- properties: {
- query: { type: 'string', description: 'Free-text description of the current task/situation. Runs natural-language semantic search over the network. Provide query and/or signals.' },
- signals: { type: 'array', items: { type: 'string' }, description: 'Signal keywords, e.g. ["log_error","perf_bottleneck","test_failure"]. Provide query and/or signals.' },
- mode: { type: 'string', enum: ['semantic', 'exact'], default: 'semantic' },
- limit: { type: 'integer', minimum: 1, maximum: 25, default: 5 },
- },
- additionalProperties: false,
- },
- handler: (a) => {
- const query = typeof a.query === 'string' ? a.query.trim() : '';
- const signals = Array.isArray(a.signals) ? a.signals : [];
- if (!query && signals.length === 0) {
- return Promise.resolve({
- ok: false,
- error: 'Provide a free-text `query` and/or a `signals` array.',
- });
- }
- return proxyFetch('POST', '/asset/search', {
- query: query || undefined,
- signals: signals.length ? signals : undefined,
- mode: a.mode || 'semantic',
- limit: a.limit || 5,
- });
- },
- },
- {
- name: 'evolver_fetch_asset',
- description: 'Fetch the full content of one or more evolution assets by their IDs (e.g. "sha256:abc..."), as returned by evolver_search_assets. After you actually reuse any of these in your work, call evolver_report_reuse with their IDs so the original author gets credit.',
- inputSchema: {
- type: 'object',
- properties: { asset_ids: { type: 'array', items: { type: 'string' }, minItems: 1 } },
- required: ['asset_ids'],
- additionalProperties: false,
- },
- handler: async (a) => {
- const res = await proxyFetch('POST', '/asset/fetch', { asset_ids: a.asset_ids });
- if (!res.ok) return res;
- const data = res.data && typeof res.data === 'object' ? res.data : { value: res.data };
- return {
- ok: true,
- data: {
- ...data,
- _reuse_hint: 'If you build on any of these assets, call evolver_report_reuse with the asset_ids you reused so the author gets credit.',
- },
- };
- },
- },
- {
- name: 'evolver_report_reuse',
- description: 'Report that you actually REUSED one or more fetched Gene/Capsule assets in your work (not just viewed them). This credits the original authors and feeds the reuse-reward network. Call it after you build on an asset fetched via evolver_fetch_asset; pass the asset_ids you genuinely reused.',
- inputSchema: {
- type: 'object',
- properties: {
- asset_ids: { type: 'array', items: { type: 'string' }, minItems: 1, description: 'The asset IDs you reused (as returned by evolver_fetch_asset).' },
- outcome: { type: 'string', enum: ['success', 'failed'], description: 'Whether reusing them worked out. Defaults to success.' },
- signals: { type: 'array', items: { type: 'string' }, description: 'Optional signal keywords describing the task you reused them on.' },
- },
- required: ['asset_ids'],
- additionalProperties: false,
- },
- handler: (a) => proxyFetch('POST', '/asset/report-reuse', {
- used_asset_ids: a.asset_ids,
- status: a.outcome || 'success',
- signals: a.signals,
- }),
- },
- {
- name: 'evolver_publish_asset',
- description: 'Publish one or more evolution assets (Genes/Capsules) to the EvoMap Hub for review. Queued locally and synced by the Proxy in the background; poll asset_submit_result with evolver_poll to see the Hub decision.',
- inputSchema: {
- type: 'object',
- properties: {
- assets: {
- type: 'array', minItems: 1,
- items: {
- type: 'object',
- properties: {
- type: { type: 'string', enum: ['Gene', 'Capsule'] },
- content: { type: 'string' },
- summary: { type: 'string' },
- signals: { type: 'array', items: { type: 'string' } },
- },
- required: ['type', 'content'],
- },
- },
- },
- required: ['assets'],
- additionalProperties: false,
- },
- handler: (a) => proxyFetch('POST', '/asset/submit', { assets: a.assets }),
- },
- {
- name: 'evolver_distill_conversation',
- description: 'Distill a reusable Gene/Capsule from the current Cursor conversation. Provide a concrete summary, strategy/evidence, artifacts, and validation; the Proxy gates quality, stores locally, and queues Hub publishing.',
- inputSchema: {
- type: 'object',
- properties: {
- title: { type: 'string' },
- summary: { type: 'string', description: 'Concrete reusable lesson or capability distilled from the conversation.' },
- platform: { type: 'string', default: 'cursor' },
- thread_id: { type: 'string' },
- user_prompt: { type: 'string' },
- assistant_summary: { type: 'string' },
- transcript: { type: 'string' },
- signals: { type: 'array', items: { type: 'string' } },
- strategy: { type: 'array', items: { type: 'string' } },
- artifacts: { type: 'array', items: { type: 'string' } },
- validation: { type: 'array', items: { type: 'string' } },
- persist: { type: 'boolean', default: true },
- publish: { type: 'boolean', default: true },
- min_score: { type: 'integer', minimum: 1, maximum: 10, default: 5 },
- },
- required: ['summary'],
- additionalProperties: false,
- },
- handler: (a) => proxyFetch('POST', '/conversation/distill', { ...a, platform: a.platform || 'cursor' }),
- },
- {
- name: 'evolver_poll',
- description: 'Poll the local mailbox for inbound messages by type, e.g. "asset_submit_result" (Hub review decisions), "hub_event", or "task_available". Returns and does not auto-acknowledge.',
- inputSchema: {
- type: 'object',
- properties: {
- type: { type: 'string', description: 'Message type filter, e.g. "asset_submit_result".' },
- limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 },
- },
- additionalProperties: false,
- },
- handler: (a) => proxyFetch('POST', '/mailbox/poll', { type: a.type, limit: a.limit || 10 }),
- },
-];
-
-const TOOL_BY_NAME = Object.fromEntries(TOOLS.map(t => [t.name, t]));
-
-// ---- JSON-RPC plumbing ---------------------------------------------------
-
-function send(msg) { process.stdout.write(JSON.stringify(msg) + '\n'); }
-function reply(id, result) { send({ jsonrpc: '2.0', id, result }); }
-function replyError(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); }
-
-async function handleToolCall(id, params) {
- const tool = TOOL_BY_NAME[params?.name];
- if (!tool) return replyError(id, -32602, `Unknown tool: ${params?.name}`);
- let out;
- try {
- out = await tool.handler(params.arguments || {});
- } catch (e) {
- out = { ok: false, error: `Tool execution failed: ${e.message}` };
- }
- const text = out.ok ? JSON.stringify(out.data, null, 2) : out.error;
- reply(id, { content: [{ type: 'text', text }], isError: !out.ok });
-}
-
-async function dispatch(req) {
- const { id, method, params } = req;
- const isNotification = id === undefined || id === null;
-
- switch (method) {
- case 'initialize':
- return reply(id, {
- protocolVersion: params?.protocolVersion || DEFAULT_PROTOCOL,
- capabilities: { tools: {} },
- serverInfo: SERVER,
- instructions: 'Evolver Proxy bridge. Use evolver_recipe_search then evolver_recipe_express before substantive networked work. evolver_search_assets is Gene/Capsule fallback when no Recipe hits. evolver_status to check the Proxy; evolver_report_reuse after you actually reuse a fetched asset; evolver_publish_asset to contribute new ones.',
- });
- case 'notifications/initialized':
- case 'initialized':
- return; // notification — no response
- case 'ping':
- return reply(id, {});
- case 'tools/list':
- return reply(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
- case 'tools/call':
- return handleToolCall(id, params);
- default:
- if (isNotification) return; // ignore unknown notifications
- return replyError(id, -32601, `Method not found: ${method}`);
- }
-}
-
-// Track in-flight (async) requests so we never exit on stdin close while a
-// tool call's reply is still pending — otherwise the last response is dropped.
-let pending = 0;
-let closed = false;
-function maybeExit() { if (closed && pending === 0) process.exit(0); }
-
-const rl = createInterface({ input: process.stdin });
-rl.on('line', (line) => {
- const trimmed = line.trim();
- if (!trimmed) return;
- let req;
- try { req = JSON.parse(trimmed); } catch { log('dropping non-JSON line'); return; }
- pending++;
- Promise.resolve(dispatch(req))
- .catch(e => {
- log('dispatch error:', e.message);
- if (req && req.id != null) replyError(req.id, -32603, `Internal error: ${e.message}`);
- })
- .finally(() => { pending--; maybeExit(); });
-});
-rl.on('close', () => { closed = true; maybeExit(); });
-
-log(`ready (server ${SERVER.version}); proxy base ${readProxySettings().url}`);
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e973414
--- /dev/null
+++ b/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "evolver-cursor-plugin",
+ "version": "2.2.0",
+ "private": true,
+ "scripts": {
+ "test": "node --test test/*.test.mjs"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+}
diff --git a/rules/evolution-memory.mdc b/rules/evolution-memory.mdc
deleted file mode 100644
index d8b4abf..0000000
--- a/rules/evolution-memory.mdc
+++ /dev/null
@@ -1,25 +0,0 @@
----
-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 `evolver` skill and the `/evolve` command provide the full
-workflow. Installing `@evomap/evolver` (`npm install -g @evomap/evolver@2`)
-upgrades the local-only memory into the full review-and-solidify pipeline.
diff --git a/scripts/background-worker.mjs b/scripts/background-worker.mjs
new file mode 100644
index 0000000..25b7336
--- /dev/null
+++ b/scripts/background-worker.mjs
@@ -0,0 +1,33 @@
+#!/usr/bin/env node
+import { DISTILL_TASK, DRAIN_TASK, ENGINE_TASK } from '../lib/background.mjs';
+import { cursorModelRunner } from '../lib/cursor-agent.mjs';
+import { DISTILL_TIMEOUT_MS, distillLeftovers } from '../lib/distiller.mjs';
+import { setUpEngine } from '../lib/engine-job.mjs';
+import { engineRuntime } from '../lib/engine-runtime.mjs';
+import { recordReuseLocally } from '../lib/local-ledger.mjs';
+import { createProxyClient } from '../lib/proxy.mjs';
+import { drainOutbox } from '../lib/report-queue.mjs';
+import { pruneOldSessions } from '../lib/session-state.mjs';
+
+async function ranCleanly(work) {
+ try {
+ await work();
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+const tasks = new Set(process.argv.slice(2));
+const results = [await ranCleanly(() => pruneOldSessions())];
+if (tasks.has(ENGINE_TASK)) results.push(await ranCleanly(() => setUpEngine(engineRuntime())));
+if (tasks.has(DRAIN_TASK)) {
+ results.push(await ranCleanly(() => drainOutbox({ proxyFetch: createProxyClient(), recordLocally: recordReuseLocally })));
+}
+if (tasks.has(DISTILL_TASK)) {
+ results.push(await ranCleanly(() => distillLeftovers({
+ proxyFetch: createProxyClient({ timeoutMs: DISTILL_TIMEOUT_MS }),
+ runModel: cursorModelRunner(),
+ })));
+}
+process.exit(results.every(Boolean) ? 0 : 1);
diff --git a/skills/evolver/SKILL.md b/skills/evolver/SKILL.md
deleted file mode 100644
index 9dbdcc2..0000000
--- a/skills/evolver/SKILL.md
+++ /dev/null
@@ -1,93 +0,0 @@
----
-name: 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".
----
-
-# 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@2
-```
-
-This gives you the engine's CLI (e.g. `evolver run`, surfaced as the `/run`
-command) 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.
-
-## MCP tools
-
-This plugin bundles a lightweight MCP bridge (`evolver-proxy`) exposing the local
-EvoMap Proxy mailbox:
-
-- `evolver_recipe_search` — default first step: find Hub Recipes (ordered Gene/Capsule DNA). **Call this before substantive networked work.**
-- `evolver_recipe_express` — express a Recipe by id. Hub unfolds Gene then Capsule steps; do not parse recipe JSON locally.
-- `evolver_search_assets` — Fallback: find genes/capsules by free-text `query` and/or signal keywords when no Recipe matches.
-- `evolver_status` — Proxy state (node id, pending counts, last sync).
-- `evolver_fetch_asset` / `evolver_report_reuse` / `evolver_publish_asset` / `evolver_distill_conversation` / `evolver_poll`.
-
-After `evolver_fetch_asset`, if you actually reuse an asset, call `evolver_report_reuse` with those IDs so the original author gets credit.
-
-Use `evolver_distill_conversation` only when the current Cursor conversation produced a concrete reusable capability. Include a summary, strategy steps, artifact paths/links, and validation evidence so the Proxy can reject weak or noisy candidates.
-
-The tools degrade gracefully when the Proxy isn't running (the local memory hooks
-still work). The richer, full `gep_*` surface is the separate
-[`@evomap/gep-mcp-server`](https://github.com/EvoMap/gep-mcp-server) — add it to
-your MCP config if you want it; the two compose.
diff --git a/test/applied.test.mjs b/test/applied.test.mjs
new file mode 100644
index 0000000..ccce9b4
--- /dev/null
+++ b/test/applied.test.mjs
@@ -0,0 +1,86 @@
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { test } from 'node:test';
+
+import { appliedVerdictOf, concreteFeaturesOf, strategyStepsOf } from '../lib/applied.mjs';
+
+const STRATEGY = [
+ '[Evolution Memory] Fix flaky Redis connection in tests (EvoMap network):',
+ '1. Set `maxRetriesPerRequest` to null in lib/redis.js',
+ '2. Run npm test to confirm the suite passes',
+ '3. Wrap the client in a singleton factory module',
+ '4. Close the connection in the global teardown hook',
+ '',
+ 'Apply them where they fit.',
+].join('\n');
+
+test('only the numbered steps of an injected strategy are matched', () => {
+ assert.equal(strategyStepsOf(STRATEGY).length, 4);
+});
+
+test('backticked names, paths and commands are concrete features', () => {
+ assert.deepEqual(concreteFeaturesOf('Set `maxRetriesPerRequest` to null in lib/redis.js'), ['maxretriesperrequest', 'lib/redis.js']);
+ assert.deepEqual(concreteFeaturesOf('Run npm test to confirm'), ['npm test']);
+});
+
+test('a turn that touched the named file and ran the named command used the strategy', () => {
+ const verdict = appliedVerdictOf({
+ strategyText: STRATEGY,
+ commands: ['npm test'],
+ paths: ['/repo/lib/redis.js'],
+ answer: 'I wrapped the client in a singleton factory module.',
+ });
+ assert.equal(verdict.verdict, 'used');
+ assert.equal(verdict.hits, 3);
+ assert.equal(verdict.steps, 4);
+});
+
+test('one matching step out of four is partial', () => {
+ const verdict = appliedVerdictOf({ strategyText: STRATEGY, commands: ['npm test'], paths: [], answer: 'Done.' });
+ assert.equal(verdict.verdict, 'partial');
+ assert.equal(verdict.ratio, 0.25);
+});
+
+test('a turn sharing nothing with the strategy left it unused', () => {
+ const verdict = appliedVerdictOf({ strategyText: STRATEGY, commands: ['ls'], paths: ['/repo/README.md'], answer: 'Renamed the heading.' });
+ assert.equal(verdict.verdict, 'unused');
+ assert.equal(verdict.hits, 0);
+});
+
+test('generic words alone do not count as following a prose step', () => {
+ const prose = '1. Make sure you always check the result before you report the outcome';
+ assert.equal(appliedVerdictOf({ strategyText: prose, commands: ['ls'], paths: [], answer: 'make sure always check before report outcome' }).verdict, 'unused');
+});
+
+test('a strategy with no steps cannot be judged', () => {
+ assert.deepEqual(appliedVerdictOf({ strategyText: '', commands: ['npm test'], paths: [], answer: '' }), { verdict: 'unknown', hits: 0, steps: 0, ratio: 0 });
+});
+
+const LRU_TURN = JSON.parse(readFileSync(new URL('./fixtures/lru-turn.json', import.meta.url), 'utf8'));
+
+test('a strategy carried out in the code the turn wrote is used, whatever language the answer is in', () => {
+ const verdict = appliedVerdictOf(LRU_TURN);
+ assert.equal(verdict.verdict, 'used');
+ assert.equal(verdict.steps, 5);
+ assert.ok(verdict.hits >= 3, `hits ${verdict.hits}`);
+});
+
+test('the same turn without its written code shows why edits must be matched', () => {
+ assert.equal(appliedVerdictOf({ ...LRU_TURN, edits: [] }).hits, 0);
+});
+
+test('a prose word that reads like a command does not stop a step from matching on its terms', () => {
+ const step = '1. On get, move the accessed node to the head of the list to mark it as most recently used';
+ const edits = ['moveToHead(node) { if (node === this.head) return } // most recently used'];
+ assert.equal(appliedVerdictOf({ strategyText: step, commands: [], paths: [], edits, answer: '' }).verdict, 'used');
+});
+
+test('work that shares nothing with the strategy is unused', () => {
+ const verdict = appliedVerdictOf({ ...LRU_TURN, edits: ['export const greeting = "hello";\n'], commands: ['ls'], paths: ['/tmp/x/greeting.js'], answer: '好了。' });
+ assert.equal(verdict.verdict, 'unused');
+});
+
+test('a turn that left only an answer, or a strategy without steps, cannot be judged', () => {
+ assert.equal(appliedVerdictOf({ strategyText: LRU_TURN.strategyText, commands: [], paths: [], edits: [], answer: '用 Map 加双向链表即可。' }).verdict, 'unknown');
+ assert.equal(appliedVerdictOf({ strategyText: 'no numbered steps here', commands: ['npm test'], paths: [], edits: [], answer: '' }).verdict, 'unknown');
+});
diff --git a/test/checks.test.mjs b/test/checks.test.mjs
new file mode 100644
index 0000000..03732c0
--- /dev/null
+++ b/test/checks.test.mjs
@@ -0,0 +1,37 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { checkEvidenceOf, checkIdentityOf, checkKindOf } from '../lib/checks.mjs';
+
+test('every check command maps onto the four kinds the Hub accepts', () => {
+ assert.equal(checkKindOf('npx -p typescript tsc -p .'), 'typecheck');
+ assert.equal(checkKindOf('npm run lint'), 'lint');
+ assert.equal(checkKindOf('npm run validate'), 'lint');
+ assert.equal(checkKindOf('node --test test/*.test.js'), 'test');
+ assert.equal(checkKindOf('cargo build --release'), 'build');
+ assert.equal(checkKindOf('make'), 'build');
+});
+
+test('the last check decides, and a failure of that same command earlier marks a turnaround', () => {
+ assert.equal(checkEvidenceOf([]), undefined);
+ assert.deepEqual(checkEvidenceOf([{ command: 'npm test', failed: true }, { command: 'npm test', failed: false }]), { kind: 'test', passed: true, fail_then_pass: true });
+ assert.deepEqual(checkEvidenceOf([{ command: 'npm run lint', failed: true }, { command: 'npm test', failed: false }]), { kind: 'test', passed: true, fail_then_pass: false });
+ assert.deepEqual(checkEvidenceOf([{ command: 'npm test', failed: false }, { command: 'npm test', failed: true }]), { kind: 'test', passed: false, fail_then_pass: false });
+});
+
+test('the same check is recognised when only its output trimming differs', () => {
+ const checks = [
+ { command: 'cd /tmp/lru-e2e && node --test 2>&1 | tail -12', failed: true },
+ { command: 'cd /tmp/lru-e2e && node --test 2>&1 | tail -9', failed: false },
+ ];
+ assert.deepEqual(checkEvidenceOf(checks), { kind: 'test', passed: true, fail_then_pass: true });
+ assert.equal(checkIdentityOf('npm test 2>&1 | grep -v "^ok" | head -40'), 'npm test');
+ assert.equal(checkIdentityOf('npm run build > build.log 2>&1 && npm test | tail -3'), 'npm run build && npm test');
+});
+
+test('different checks stay different, and a logical or is not an output pipe', () => {
+ assert.notEqual(checkIdentityOf('npm test | tail -5'), checkIdentityOf('npm run lint | tail -5'));
+ assert.notEqual(checkIdentityOf('cd a && npm test'), checkIdentityOf('cd b && npm test'));
+ assert.equal(checkIdentityOf('npm test || true'), 'npm test || true');
+ assert.equal(checkEvidenceOf([{ command: 'npm run lint | tail -3', failed: true }, { command: 'npm test | tail -3', failed: false }]).fail_then_pass, false);
+});
diff --git a/test/commands.test.mjs b/test/commands.test.mjs
new file mode 100644
index 0000000..1bdbe3a
--- /dev/null
+++ b/test/commands.test.mjs
@@ -0,0 +1,98 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { readdirSync, readFileSync } from 'node:fs';
+
+import { argumentsOf, cmdArgument, endTree, evolverCommands, isMissingExecutable, runEvolverCli, typedCommandOf } from '../lib/commands.mjs';
+
+const commands = evolverCommands({
+ status: async () => 'OK Proxy',
+ search: async (text) => ({ ok: true, data: { text } }),
+ distillNow: async (focus) => (focus ? { status: 'sent', data: { id: 'gene', focus } } : { status: 'nothing' }),
+ runCli: async (args) => ({ kind: 'success', text: args.join(' ') }),
+});
+const named = (name) => commands.find((command) => command.name === `evolver-${name}`);
+
+const COMMANDS_DIR = new URL('../commands/', import.meta.url);
+const descriptionOf = (file) => /^description: (.*)$/m.exec(readFileSync(new URL(file, COMMANDS_DIR), 'utf8'))[1].replace(/^"(.*)"$/, '$1');
+
+test('the plugin runs dsh\'s commands but evolve, and one that takes no argument declares no input hint', () => {
+ assert.deepEqual(commands.map((command) => command.name), [
+ 'evolver-status', 'evolver-search', 'evolver-distill', 'evolver-sync', 'evolver-review', 'evolver-run', 'evolver-solidify',
+ ]);
+ assert.equal(named('status').input, undefined);
+ assert.ok(commands.filter((command) => command.name !== 'evolver-status').every((command) => command.input.hint));
+});
+
+test('search needs text, distill says when there is nothing, and engine commands pass their arguments through', async () => {
+ assert.equal((await named('search').handler({ args: [] })).kind, 'error');
+ assert.match((await named('search').handler({ args: ['flaky', 'test'] })).text, /"flaky test"/);
+ assert.match((await named('distill').handler({ args: [] })).text, /Nothing to distill yet/);
+ assert.match((await named('distill').handler({ args: ['the', 'backoff'] })).text, /"focus": "the backoff"/);
+ assert.equal((await named('run').handler({ args: ['--strategy=balanced'] })).text, 'run --strategy=balanced');
+});
+
+test('every command has a command file for the slash menu, with the same description; evolve is the dsh prompt', () => {
+ const files = readdirSync(COMMANDS_DIR).sort();
+ assert.deepEqual(files, [...commands.map((command) => `${command.name}.md`), 'evolver-evolve.md'].sort());
+ for (const command of commands) assert.equal(descriptionOf(`${command.name}.md`), command.description);
+ const evolve = readFileSync(new URL('evolver-evolve.md', COMMANDS_DIR), 'utf8');
+ assert.match(evolve, /\nTake a deliberate evolution step for the current task\./);
+ assert.match(evolve, /`\/evolver-distill` distills it now/);
+ assert.doesNotMatch(evolve, /evolver_/);
+});
+
+test('a typed command is read with dsh quoting, a plugin namespace dropped, and only at the start of the prompt', () => {
+ assert.deepEqual(typedCommandOf('/evolver-sync --scope="a b" --dry-run'), { name: 'evolver-sync', args: ['--scope=a b', '--dry-run'] });
+ assert.deepEqual(typedCommandOf('/evolver:evolver-status'), { name: 'evolver-status', args: [] });
+ assert.equal(typedCommandOf('please run /evolver-status'), null);
+ assert.equal(typedCommandOf('/evolvers'), null);
+ assert.deepEqual(argumentsOf(String.raw`--summary="two \"quoted\" words" a\ b 'c\d'`), ['--summary=two "quoted" words', 'a b', String.raw`c\d`]);
+});
+
+test('a missing evolver executable falls back to the npx package this plugin is built against', async () => {
+ const calls = [];
+ const run = async (file, args) => {
+ calls.push([file, ...args]);
+ return file === 'evolver' ? { error: Object.assign(new Error('spawn evolver ENOENT'), { code: 'ENOENT' }) } : { error: null, output: 'synced' };
+ };
+ assert.deepEqual(await runEvolverCli(['sync', '--dry-run'], { run }), { kind: 'success', text: 'synced' });
+ assert.deepEqual(calls[1], ['npx', '-y', '@evomap/evolver@^2.0.44', 'sync', '--dry-run']);
+});
+
+test('on Windows a failed run whose evolver `where` cannot find falls back to npx, though cmd.exe reports no ENOENT', async () => {
+ const notRecognized = Object.assign(new Error('Command failed: evolver sync'), { code: 1 });
+ const windows = (found) => ({ platform: 'win32', isOnPath: async () => found });
+ assert.equal(await isMissingExecutable(notRecognized, 'evolver', windows(false)), true);
+ assert.equal(await isMissingExecutable(notRecognized, 'evolver', windows(true)), false);
+ assert.equal(await isMissingExecutable(notRecognized, 'evolver', { platform: 'darwin', isOnPath: async () => false }), false);
+ assert.equal(await isMissingExecutable(null, 'evolver', windows(false)), false);
+
+ const calls = [];
+ const run = async (file, args) => {
+ calls.push([file, ...args]);
+ return file === 'evolver' ? { error: notRecognized, output: "'evolver' is not recognized" } : { error: null, output: 'synced' };
+ };
+ const isMissing = (error, name) => isMissingExecutable(error, name, windows(false));
+ assert.deepEqual(await runEvolverCli(['sync'], { run, isMissing }), { kind: 'success', text: 'synced' });
+ assert.deepEqual(calls[1], ['npx', '-y', '@evomap/evolver@^2.0.44', 'sync']);
+});
+
+test('a Windows argument reaches the npm shim as one literal argument: cmd metacharacters are escaped for cmd.exe and the shim', () => {
+ assert.equal(cmdArgument('sync'), '^^^"sync^^^"');
+ assert.equal(cmdArgument('a&calc'), '^^^"a^^^&calc^^^"');
+ assert.equal(cmdArgument('x|y>z'), '^^^"x^^^|y^^^>z^^^"');
+ assert.equal(cmdArgument('%PATH%'), '^^^"^^^%PATH^^^%^^^"');
+ assert.equal(cmdArgument('say "hi"'), '^^^"say^^^ \\^^^"hi\\^^^"^^^"');
+ assert.equal(cmdArgument('C:\\dir\\'), '^^^"C:\\dir\\\\^^^"');
+});
+
+test('a timed-out CLI run ends its whole tree: the process group on POSIX, taskkill /T on Windows', () => {
+ const killed = [];
+ endTree(4242, { platform: 'win32', killWindowsTree: (pid) => killed.push(['taskkill', pid]), signal: () => killed.push(['signal']) });
+ assert.deepEqual(killed, [['taskkill', 4242]]);
+
+ const signals = [];
+ endTree(4242, { platform: 'linux', killWindowsTree: () => signals.push('taskkill'), signal: (pid, name) => signals.push(`${pid}:${name}`) });
+ assert.deepEqual(signals, ['4242:SIGTERM']);
+});
diff --git a/test/cursor-agent.test.mjs b/test/cursor-agent.test.mjs
new file mode 100644
index 0000000..7c4bb11
--- /dev/null
+++ b/test/cursor-agent.test.mjs
@@ -0,0 +1,32 @@
+import assert from 'node:assert/strict';
+import { join } from 'node:path';
+import { test } from 'node:test';
+
+import { completionOf, cursorAgentArguments, cursorAgentCli, cursorModelRunner } from '../lib/cursor-agent.mjs';
+
+test('the CLI is cursor-agent on PATH or in ~/.local/bin, else an agent that resolves into the CLI install', () => {
+ const env = { PATH: ['/usr/bin', '/opt/bin'].join(':') };
+ const present = (...files) => (file) => files.includes(file);
+ assert.equal(cursorAgentCli({ env, home: '/h', exists: present('/opt/bin/cursor-agent', '/h/.local/bin/cursor-agent') }), '/opt/bin/cursor-agent');
+ assert.equal(cursorAgentCli({ env, home: '/h', exists: present('/h/.local/bin/cursor-agent') }), join('/h', '.local', 'bin', 'cursor-agent'));
+ assert.equal(cursorAgentCli({ env, home: '/h', exists: present('/usr/bin/agent'), isCli: () => true }), '/usr/bin/agent');
+ assert.equal(cursorAgentCli({ env, home: '/h', exists: present('/usr/bin/agent'), isCli: () => false }), null, 'some other agent');
+ assert.equal(cursorAgentCli({ env, home: '/h', platform: 'win32', exists: present('/opt/bin/cursor-agent.cmd') }), null, 'a shim needs cmd.exe');
+ assert.equal(cursorAgentCli({ env, home: '/h', platform: 'win32', exists: present('/opt/bin/cursor-agent.exe') }), '/opt/bin/cursor-agent.exe');
+});
+
+test('the run is one read-only answer in a scratch workspace, on the session model when Cursor named one', () => {
+ assert.deepEqual(cursorAgentArguments('gpt-5', '/tmp/w', 'P'), ['-p', '--output-format', 'json', '--mode', 'ask', '--trust', '--workspace', '/tmp/w', '--model', 'gpt-5', 'P']);
+ assert.deepEqual(cursorAgentArguments('default', '/tmp/w', 'P').includes('--model'), false);
+ assert.deepEqual(cursorAgentArguments('a b; rm', '/tmp/w', 'P').includes('--model'), false);
+});
+
+test('the answer is the result of the JSON object; a failed run throws with its stderr', () => {
+ assert.deepEqual(completionOf({ code: 0, stdout: '{"type":"result","is_error":false,"result":"{}"}' }), { answer: '{}', usage: null });
+ assert.throws(() => completionOf({ code: 1, stdout: '', stderr: 'Authentication required' }), /cursor-agent exited 1: Authentication required/);
+ assert.throws(() => completionOf({ code: 0, stdout: '{"type":"result","is_error":true,"result":"x"}' }), /cursor-agent exited 0/);
+});
+
+test('no CLI rejects with ENOENT, which a distillation reads as no model', async () => {
+ await assert.rejects(cursorModelRunner({ cli: null })('m', 'p'), { code: 'ENOENT' });
+});
diff --git a/test/engine-setup.test.mjs b/test/engine-setup.test.mjs
new file mode 100644
index 0000000..a73ac04
--- /dev/null
+++ b/test/engine-setup.test.mjs
@@ -0,0 +1,167 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { engineNoticeOf } from '../lib/engine-notice.mjs';
+import { isSetupDue, prepareEngine, setupErrorOutcome } from '../lib/engine-setup.mjs';
+
+const NOW = 1_800_000_000_000;
+const MINUTE = 60 * 1000;
+
+function fakeRuntime({ node = '22.22.2', managed = null, onPath = null, latest = '2.0.44', installError = null, running = null, startsProxy = true, startError = null } = {}) {
+ const calls = [];
+ let managedEngine = managed && engineAt('managed', managed);
+ let proxy = running && { version: running };
+ function engineAt(source, version) {
+ return {
+ source,
+ version,
+ run: async (args) => {
+ calls.push([source, ...args]);
+ if (startError) return { ok: false, error: startError };
+ if (startsProxy) proxy = { version };
+ return { ok: true };
+ },
+ };
+ }
+ return {
+ calls,
+ nodeVersion: node,
+ now: () => NOW,
+ managedEngine: async () => managedEngine,
+ pathEngine: async () => onPath && engineAt('path', onPath),
+ runningProxy: async () => proxy,
+ waitForProxy: async () => proxy,
+ installLatest: async () => {
+ calls.push(['npm', 'install']);
+ if (installError) return { ok: false, error: installError };
+ managedEngine = engineAt('managed', latest);
+ return { ok: true };
+ },
+ };
+}
+
+test('a host without Evolver gets the latest installed and its Proxy started', async () => {
+ const runtime = fakeRuntime();
+ const outcome = await prepareEngine(runtime, null);
+ assert.deepEqual(runtime.calls, [['npm', 'install'], ['managed', 'lifecycle', 'start']]);
+ assert.deepEqual(outcome, { version: '2.0.44', source: 'managed', status: 'installed', proxyVersion: '2.0.44', at: NOW, failures: 0 });
+ assert.match(engineNoticeOf(outcome), /was missing, so this plugin installed Evolver 2\.0\.44 and started its Proxy/);
+});
+
+test('an Evolver older than the minimum is upgraded, and a stale running Proxy restarted', async () => {
+ const runtime = fakeRuntime({ managed: '2.0.42', running: '2.0.42' });
+ const outcome = await prepareEngine(runtime, null);
+ assert.deepEqual(runtime.calls, [['npm', 'install'], ['managed', 'lifecycle', 'restart']]);
+ assert.equal(outcome.status, 'upgraded');
+ assert.equal(outcome.previous, '2.0.42');
+ assert.match(engineNoticeOf(outcome), /Evolver 2\.0\.42 was older than 2\.0\.44, so this plugin upgraded it to 2\.0\.44 and started its Proxy/);
+});
+
+test('an Evolver already on PATH that is new enough is used as it is', async () => {
+ const runtime = fakeRuntime({ onPath: '2.1.0' });
+ const outcome = await prepareEngine(runtime, null);
+ assert.deepEqual(runtime.calls, [['path', 'lifecycle', 'start']]);
+ assert.equal(outcome.status, 'started');
+ assert.equal(outcome.source, 'path');
+});
+
+test('a running Proxy that is new enough is left alone', async () => {
+ const runtime = fakeRuntime({ managed: '2.0.44', running: '2.0.44' });
+ assert.equal((await prepareEngine(runtime, null)).status, 'ready');
+ assert.deepEqual(runtime.calls, []);
+});
+
+test('a stale Proxy this plugin cannot restart asks the person to restart it', async () => {
+ const runtime = fakeRuntime({ managed: '2.0.44', running: '2.0.40', startsProxy: false });
+ const outcome = await prepareEngine(runtime, null);
+ assert.equal(outcome.status, 'restart_needed');
+ assert.equal(outcome.proxyVersion, '2.0.40');
+ assert.match(engineNoticeOf(outcome), /evolver lifecycle restart/);
+});
+
+test('a failed install with nothing to fall back on backs off and says how to install by hand', async () => {
+ const runtime = fakeRuntime({ installError: 'npm ERR! code ENOTFOUND' });
+ const first = await prepareEngine(runtime, null);
+ assert.deepEqual(first, { status: 'install_failed', error: 'npm ERR! code ENOTFOUND', at: NOW, failures: 1, retryAt: NOW + 10 * MINUTE });
+ assert.equal(isSetupDue(first, NOW + 9 * MINUTE), false);
+ assert.equal(isSetupDue(first, NOW + 10 * MINUTE), true);
+ const third = await prepareEngine(runtime, { ...first, failures: 2 });
+ assert.equal(third.retryAt, NOW + 40 * MINUTE);
+ assert.equal((await prepareEngine(runtime, { ...first, failures: 20 })).retryAt, NOW + 24 * 60 * MINUTE);
+ assert.match(engineNoticeOf(first), /Installing Evolver failed \(npm ERR! code ENOTFOUND\)\. To set it up by hand: `npm install -g @evomap\/evolver@latest`, then `evolver lifecycle start`\. The plugin retries automatically after/);
+});
+
+test('a failed upgrade still starts the older Evolver it has, and retries the upgrade later', async () => {
+ const runtime = fakeRuntime({ managed: '2.0.42', installError: 'EACCES' });
+ const outcome = await prepareEngine(runtime, null);
+ assert.equal(outcome.status, 'outdated');
+ assert.equal(outcome.failures, 0);
+ assert.equal(outcome.installError, 'EACCES');
+ assert.equal(outcome.installRetryAt, NOW + 6 * 60 * MINUTE);
+ assert.deepEqual(runtime.calls, [['npm', 'install'], ['managed', 'lifecycle', 'start']]);
+});
+
+test('when npm still has nothing new enough, the upgrade is retried only every six hours', async () => {
+ const runtime = fakeRuntime({ latest: '2.0.42' });
+ const first = await prepareEngine(runtime, null);
+ assert.equal(first.status, 'outdated');
+ assert.equal(first.installRetryAt, NOW + 6 * 60 * MINUTE);
+
+ const later = fakeRuntime({ managed: '2.0.42' });
+ const second = await prepareEngine(later, first);
+ assert.deepEqual(later.calls, [['managed', 'lifecycle', 'start']]);
+ assert.equal(second.status, 'outdated');
+ assert.equal(second.installRetryAt, first.installRetryAt);
+ assert.match(engineNoticeOf(second), /Evolver 2\.0\.42 is the newest this plugin could install, and reuse evidence needs 2\.0\.44 or newer/);
+});
+
+test('a Proxy that does not come up is reported with its error and backed off', async () => {
+ const runtime = fakeRuntime({ managed: '2.0.44', startError: 'public legacy mode needs EVOMAP_NODE_SECRET' });
+ const outcome = await prepareEngine(runtime, null);
+ assert.equal(outcome.status, 'start_failed');
+ assert.equal(outcome.error, 'public legacy mode needs EVOMAP_NODE_SECRET');
+ assert.equal(outcome.retryAt, NOW + 10 * MINUTE);
+ assert.match(engineNoticeOf(outcome), /Check ~\/\.evomap\/logs\/evolver-proxy\.log, then run `evolver lifecycle start`/);
+
+ const silent = await prepareEngine(fakeRuntime({ managed: '2.0.44', startsProxy: false }), null);
+ assert.equal(silent.error, 'the Proxy did not answer within the wait');
+});
+
+test('a Node.js too old for Evolver is reported without trying anything', async () => {
+ for (const node of ['20.19.0', '22.12.0', '23.3.0']) {
+ const runtime = fakeRuntime({ node });
+ const outcome = await prepareEngine(runtime, null);
+ assert.equal(outcome.status, 'node_unsupported', node);
+ assert.deepEqual(runtime.calls, []);
+ }
+ for (const node of ['22.13.0', '23.4.0', '24.1.0']) assert.notEqual((await prepareEngine(fakeRuntime({ node }), null)).status, 'node_unsupported', node);
+});
+
+test('a success clears earlier failures', async () => {
+ const outcome = await prepareEngine(fakeRuntime(), { status: 'install_failed', failures: 3, retryAt: NOW - 1 });
+ assert.equal(outcome.failures, 0);
+ assert.equal(outcome.retryAt, undefined);
+});
+
+test('an upgrade whose restart leaves the old Proxy running asks for a restart instead of claiming success', async () => {
+ const runtime = fakeRuntime({ managed: '2.0.42', running: '2.0.42', startsProxy: false });
+ const outcome = await prepareEngine(runtime, null);
+ assert.equal(outcome.status, 'restart_needed');
+ assert.equal(outcome.previous, '2.0.42');
+});
+
+test('a running Proxy that does not report its version is not taken as new enough', async () => {
+ const runtime = fakeRuntime({ managed: '2.0.44' });
+ runtime.runningProxy = async () => ({ version: null });
+ runtime.waitForProxy = async () => ({ version: null });
+ const outcome = await prepareEngine(runtime, null);
+ assert.deepEqual(runtime.calls, [['managed', 'lifecycle', 'restart']]);
+ assert.equal(outcome.status, 'restart_needed');
+});
+
+test('an unexpected error during setup is recorded as a failure and backed off', async () => {
+ const outcome = setupErrorOutcome(new Error('EACCES: permission denied, mkdir'), { failures: 1 }, NOW);
+ assert.deepEqual(outcome, { status: 'setup_error', error: 'EACCES: permission denied, mkdir', at: NOW, failures: 2, retryAt: NOW + 20 * MINUTE });
+ assert.doesNotMatch(setupErrorOutcome(new Error('npm ERR! 401 token=npm_abcdefghijklmnop'), null, NOW).error, /npm_abcdefghijklmnop/);
+ assert.match(engineNoticeOf(outcome), /stopped on an unexpected error \(EACCES: permission denied, mkdir\)/);
+});
diff --git a/test/engine-version.test.mjs b/test/engine-version.test.mjs
new file mode 100644
index 0000000..fd28e01
--- /dev/null
+++ b/test/engine-version.test.mjs
@@ -0,0 +1,23 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { isOlderThan, meetsMinimum } from '../lib/engine-version.mjs';
+
+test('the minimum release and anything newer meet the minimum', () => {
+ for (const version of ['2.0.44', '2.0.45', '2.1.0', '3.0.0-beta.1', 'v2.0.44\n']) assert.equal(meetsMinimum(version), true, version);
+});
+
+test('older releases, a prerelease of the minimum and unreadable output do not', () => {
+ for (const version of ['2.0.43', '2.0.44-beta.1', '2.0.0-beta.1', '', null, 'command not found']) assert.equal(meetsMinimum(version), false, String(version));
+});
+
+test('a prerelease sorts below its release', () => {
+ assert.equal(isOlderThan('2.0.44-rc.1', '2.0.44'), true);
+ assert.equal(isOlderThan('2.0.44', '2.0.44-rc.1'), false);
+});
+
+test('prereleases of the same version sort by their identifiers', () => {
+ assert.equal(isOlderThan('2.0.44-beta.2', '2.0.44-beta.10'), true);
+ assert.equal(isOlderThan('2.0.44-beta.10', '2.0.44-beta.2'), false);
+ assert.equal(isOlderThan('2.0.44-beta.1', '2.0.44-rc.1'), true);
+});
diff --git a/test/feedback-label.test.mjs b/test/feedback-label.test.mjs
new file mode 100644
index 0000000..83b1aaa
--- /dev/null
+++ b/test/feedback-label.test.mjs
@@ -0,0 +1,30 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { feedbackLabelOf } from '../lib/feedback-label.mjs';
+
+test('a prompt saying the last answer did not hold labels it a correction', () => {
+ for (const text of ['still failing', "that didn't work", '还是报错', '不对,重来']) assert.equal(feedbackLabelOf(text), 'correction', text);
+});
+
+test('thanks and confirmations label it praise', () => {
+ for (const text of ['thanks, works now', 'Perfect', '好了,谢谢', '可以了']) assert.equal(feedbackLabelOf(text), 'praise', text);
+});
+
+test('a correction wins over politeness', () => {
+ assert.equal(feedbackLabelOf("thanks, but it still doesn't work"), 'correction');
+});
+
+test('a new request carries no label', () => {
+ for (const text of ['now add a logout button', '这个行不行?', '', undefined]) assert.equal(feedbackLabelOf(text), null, String(text));
+});
+
+test('a question about whether something fails is not a correction', () => {
+ for (const text of ['这个方案不行吗?', '这个结论不对吗?', '这样不对吧?', '不行 ?']) assert.equal(feedbackLabelOf(text), null, text);
+ for (const text of ['这样不行,换个办法', '完全不对。', '还是不行?']) assert.equal(feedbackLabelOf(text), 'correction', text);
+});
+
+test('a closing 吧 asks for an opinion, while 吧 mid-sentence still corrects', () => {
+ for (const text of ['这样不行吧', '这个结论不对吧。']) assert.equal(feedbackLabelOf(text), null, text);
+ assert.equal(feedbackLabelOf('不行吧,重来'), 'correction');
+});
diff --git a/test/fixtures/fake-cursor-agent.mjs b/test/fixtures/fake-cursor-agent.mjs
new file mode 100644
index 0000000..a6b8679
--- /dev/null
+++ b/test/fixtures/fake-cursor-agent.mjs
@@ -0,0 +1,15 @@
+#!/usr/bin/env node
+import { appendFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+const GENE = { title: 'Retry uploads', summary: 'Retry a flaky upload with backoff.', strategy: '1. Measure. 2. Back off.', signals: ['retry'] };
+
+const args = process.argv.slice(2);
+appendFileSync(join(homedir(), 'fake-cursor-agent.log'), `${JSON.stringify({ args, headless: process.env.EVOLVER_HEADLESS ?? null })}\n`);
+if (process.env.FAKE_AGENT_FAIL) {
+ process.stderr.write('Authentication required. Run `agent login`.\n');
+ process.exit(1);
+}
+const answer = process.env.FAKE_AGENT_ANSWER ?? JSON.stringify(GENE);
+process.stdout.write(`${JSON.stringify({ type: 'result', subtype: 'success', is_error: false, duration_ms: 10, duration_api_ms: 9, result: answer, session_id: 'chat-x' })}\n`);
diff --git a/test/fixtures/lru-turn.json b/test/fixtures/lru-turn.json
new file mode 100644
index 0000000..8865b2a
--- /dev/null
+++ b/test/fixtures/lru-turn.json
@@ -0,0 +1,16 @@
+{
+ "strategyText": "[Evolution Memory] Implements a Least Recently Used (LRU) cache in Node.js using a doubly-linked li (EvoMap network):\n1. Use a Map for O(1) key lookups combined with a doubly-linked list to track access order\n2. On get, move the accessed node to the head of the list to mark it as most recently used\n3. On set, add new items at the head and evict the tail node when capacity is exceeded\n4. Maintain head and tail pointers for efficient insertion at front and removal from back\n5. Track cache size and provide stats method for hit ratio monitoring\n\nApply it where it fits, then report the outcome with evolver_asset_reuse_result for sha256:40c6ec437967f3ce0a2101857736b95d2770a014f452752a9421db2bee223f37.",
+ "commands": [
+ "cd /tmp/lru-e2e && node --test 2>&1 | tail -12",
+ "cd /tmp/lru-e2e && node --test 2>&1 | tail -9"
+ ],
+ "paths": [
+ "/tmp/lru-e2e/lru-cache.test.js",
+ "/tmp/lru-e2e/lru-cache.js"
+ ],
+ "edits": [
+ "import assert from 'node:assert/strict'\nimport { test } from 'node:test'\n\nimport { LruCache } from './lru-cache.js'\n\ntest('returns undefined for a missing key', () => {\n const cache = new LruCache(2)\n assert.equal(cache.get('absent'), undefined)\n})\n\ntest('returns a stored value', () => {\n const cache = new LruCache(2)\n cache.set('a', 1)\n assert.equal(cache.get('a'), 1)\n})\n\ntest('evicts the least recently used entry when capacity is exceeded', () => {\n const cache = new LruCache(2)\n cache.set('a', 1)\n cache.set('b', 2)\n cache.set('c', 3)\n assert.equal(cache.get('a'), undefined)\n assert.equal(cache.get('b'), 2)\n assert.equal(cache.get('c'), 3)\n})\n\ntest('reading an entry protects it from the next eviction', () => {\n const cache = new LruCache(2)\n cache.set('a', 1)\n cache.set('b', 2)\n cache.get('a')\n cache.set('c', 3)\n assert.equal(cache.get('a'), 1)\n assert.equal(cache.get('b'), undefined)\n})\n\ntest('overwriting a key updates its value without growing the cache', () => {\n const cache = new LruCache(2)\n cache.set('a', 1)\n cache.set('b', 2)\n cache.set('a', 10)\n cache.set('c', 3)\n assert.equal(cache.size, 2)\n assert.equal(cache.get('a'), 10)\n assert.equal(cache.get('b'), undefined)\n})\n\ntest('stores falsy values and reports them as hits', () => {\n const cache = new LruCache(2)\n cache.set('zero', 0)\n assert.equal(cache.get('zero'), 0)\n assert.equal(cache.stats().hits, 1)\n})\n\ntest('stats report hits, misses and hit ratio', () => {\n const cache = new LruCache(2)\n cache.set('a', 1)\n cache.get('a')\n cache.get('a')\n cache.get('absent')\n assert.deepEqual(cache.stats(), { size: 1, capacity: 2, hits: 2, misses: 1, hitRatio: 2 / 3 })\n})\n\ntest('hit ratio is zero before any lookup', () => {\n assert.equal(new LruCache(1).stats().hitRatio, 0)\n})\n\ntest('rejects a capacity that is not a positive integer', () => {\n assert.throws(() => new LruCache(0), RangeError)\n assert.throws(() => new LruCache(1.5), RangeError)\n})\n",
+ "export class LruCache {\n #capacity\n #nodes = new Map()\n #head = null\n #tail = null\n #hits = 0\n #misses = 0\n\n constructor(capacity) {\n if (!Number.isInteger(capacity) || capacity < 1) throw new RangeError('capacity must be a positive integer')\n this.#capacity = capacity\n }\n\n get size() {\n return this.#nodes.size\n }\n\n get(key) {\n const node = this.#nodes.get(key)\n if (!node) {\n this.#misses += 1\n return undefined\n }\n this.#hits += 1\n this.#moveToHead(node)\n return node.value\n }\n\n set(key, value) {\n const existing = this.#nodes.get(key)\n if (existing) {\n existing.value = value\n this.#moveToHead(existing)\n return\n }\n const node = { key, value, newer: null, older: null }\n this.#nodes.set(key, node)\n this.#attachAtHead(node)\n if (this.#nodes.size > this.#capacity) this.#evictTail()\n }\n\n stats() {\n const lookups = this.#hits + this.#misses\n return {\n size: this.size,\n capacity: this.#capacity,\n hits: this.#hits,\n misses: this.#misses,\n hitRatio: lookups === 0 ? 0 : this.#hits / lookups,\n }\n }\n\n #moveToHead(node) {\n if (node === this.#head) return\n this.#detach(node)\n this.#attachAtHead(node)\n }\n\n #attachAtHead(node) {\n node.newer = null\n node.older = this.#head\n if (this.#head) this.#head.newer = node\n this.#head = node\n if (!this.#tail) this.#tail = node\n }\n\n #detach(node) {\n if (node.newer) node.newer.older = node.older\n else this.#head = node.older\n if (node.older) node.older.newer = node.newer\n else this.#tail = node.newer\n }\n\n #evictTail() {\n const evicted = this.#tail\n this.#detach(evicted)\n this.#nodes.delete(evicted.key)\n }\n}\n"
+ ],
+ "answer": "LRU 缓存写完了,9 个测试全部通过。实现用 Map 加双向链表,读写和淘汰都是 O(1),带命中率统计。测试先于实现运行过一次(失败),实现后再跑通过,所以这轮留下了\"先失败后通过\"的检查记录。"
+}
\ No newline at end of file
diff --git a/test/helpers/sandbox.mjs b/test/helpers/sandbox.mjs
new file mode 100644
index 0000000..ff4ab20
--- /dev/null
+++ b/test/helpers/sandbox.mjs
@@ -0,0 +1,120 @@
+import { spawn, spawnSync } from 'node:child_process';
+import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import http from 'node:http';
+import { tmpdir } from 'node:os';
+import { delimiter, dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export const pluginRoot = fileURLToPath(new URL('../../', import.meta.url));
+const FAKE_AGENT = fileURLToPath(new URL('../fixtures/fake-cursor-agent.mjs', import.meta.url));
+const TOKEN = 'test-proxy-token';
+const SYSTEM_PATH = process.platform === 'win32' ? process.env.PATH : '/usr/bin:/bin';
+
+/** A HOME of its own for hook processes, with a `cursor-agent` on PATH that answers like `-p --output-format json`. */
+export function sandboxHome() {
+ const home = mkdtempSync(join(tmpdir(), 'evolver-cursor-home-'));
+ const bin = join(home, 'bin');
+ mkdirSync(bin, { recursive: true });
+ copyFileSync(FAKE_AGENT, join(bin, 'cursor-agent'));
+ chmodSync(join(bin, 'cursor-agent'), 0o755);
+ return {
+ home,
+ env: { ...process.env, HOME: home, USERPROFILE: home, CURSOR_PLUGIN_ROOT: pluginRoot, EVOMAP_PROXY_PORT: '1', EVOLVER_HEADLESS: '', CURSOR_PROJECT_DIR: '', PATH: [bin, dirname(process.execPath), SYSTEM_PATH].join(delimiter) },
+ cleanup: () => rmSync(home, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }),
+ };
+}
+
+/** Points this process's own state at a fresh HOME for lib-level tests; returns the restore. */
+export function freshHome(prefix = 'evolver-cursor-lib-') {
+ const previous = process.env.HOME;
+ const home = mkdtempSync(join(tmpdir(), prefix));
+ process.env.HOME = home;
+ return () => {
+ process.env.HOME = previous;
+ rmSync(home, { recursive: true, force: true });
+ };
+}
+
+export function writeProxySettings(home, { url, version = '2.0.46' }) {
+ mkdirSync(join(home, '.evolver'), { recursive: true });
+ writeFileSync(join(home, '.evolver', 'settings.json'), JSON.stringify({ proxy: { url, token: TOKEN, version } }));
+}
+
+function readBody(req) {
+ return new Promise((resolve) => {
+ let body = '';
+ req.on('data', (chunk) => {
+ body += chunk;
+ });
+ req.on('end', () => resolve(body ? JSON.parse(body) : {}));
+ });
+}
+
+export async function fakeProxy({ assets = [] } = {}) {
+ const requests = [];
+ const server = http.createServer(async (req, res) => {
+ const body = await readBody(req);
+ const reply = (status, payload) => {
+ res.writeHead(status, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(payload));
+ };
+ if (req.headers.authorization !== `Bearer ${TOKEN}`) return reply(401, { error: 'unauthorized' });
+ requests.push({ path: req.url, body });
+ if (req.url === '/proxy/status') return reply(200, { status: 'running' });
+ if (req.url === '/asset/fetch') return reply(200, { assets });
+ if (req.url === '/asset/reuse-result') return reply(200, { recorded: true, eligible: true, counted: true, weight: 1 });
+ if (req.url === '/conversation/distill') return reply(200, { id: 'gene_test' });
+ if (req.url === '/asset/search') return reply(200, { results: [{ asset_id: 'sha256:found', short_title: 'Retry with backoff' }] });
+ return reply(404, {});
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ const of = (path) => requests.filter((request) => request.path === path).map((request) => request.body);
+ return { url: `http://127.0.0.1:${server.address().port}`, requests, of, close: () => new Promise((resolve) => server.close(resolve)) };
+}
+
+export function runHook(name, input, env) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(process.execPath, [join(pluginRoot, 'hooks', `${name}.mjs`)], { env, stdio: ['pipe', 'pipe', 'pipe'] });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.on('data', (chunk) => {
+ stdout += chunk;
+ });
+ child.stderr.on('data', (chunk) => {
+ stderr += chunk;
+ });
+ child.on('error', reject);
+ // `close`, not `exit`: only `close` guarantees the hook's output has been read to the end.
+ child.on('close', (code) => {
+ if (code !== 0) return reject(new Error(`${name} exited ${code}: ${stderr}`));
+ try {
+ resolve(stdout ? JSON.parse(stdout) : {});
+ } catch (error) {
+ reject(new Error(`${name} printed output that is not JSON: ${error.message}\n${stdout}`));
+ }
+ });
+ child.stdin.end(JSON.stringify(input));
+ });
+}
+
+export async function waitFor(check, timeoutMs = 10_000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const value = await check();
+ if (Array.isArray(value) ? value.length > 0 : value) return value;
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ throw new Error('timed out waiting');
+}
+
+export function readJson(file, fallback) {
+ try {
+ return JSON.parse(readFileSync(file, 'utf8'));
+ } catch {
+ return fallback;
+ }
+}
+
+export function deadPid() {
+ return spawnSync(process.execPath, ['-e', '']).pid;
+}
diff --git a/test/hooks.test.mjs b/test/hooks.test.mjs
new file mode 100644
index 0000000..ceb59ad
--- /dev/null
+++ b/test/hooks.test.mjs
@@ -0,0 +1,516 @@
+import assert from 'node:assert/strict';
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { afterEach, test } from 'node:test';
+
+import { deadPid, fakeProxy, readJson, runHook, sandboxHome, waitFor, writeProxySettings } from './helpers/sandbox.mjs';
+
+const ASSET_ID = 'sha256:40c6ec437967f3ce0a2101857736b95d2770a014f452752a9421db2bee223f37';
+const LRU_ASSET = {
+ asset_id: ASSET_ID,
+ short_title: 'LRU cache in Node.js',
+ similarity: 0.8,
+ strategy: [
+ 'Create `lru-cache.js` exporting the cache class',
+ 'Back it with a `Map` so lookups stay O(1)',
+ 'Evict the oldest entry with `keys().next()` once over capacity',
+ 'Run `node --test` to confirm the eviction order',
+ ],
+};
+const LRU_SOURCE = 'export class LruCache {\n #entries = new Map();\n evict() { this.#entries.delete(this.#entries.keys().next().value); }\n}\n';
+const FAILED_RUN = 'not ok 1 - evicts the oldest\nAssertionError: expected 1 to equal 2\n';
+const PASSED_RUN = 'ℹ tests 2\nℹ pass 2\nℹ fail 0\n';
+const LRU_PROMPT = '用 Node 写一个 LRU cache,带 get/set 和淘汰';
+
+let sandbox = null;
+let proxy = null;
+
+afterEach(async () => {
+ await proxy?.close();
+ sandbox?.cleanup();
+ sandbox = null;
+ proxy = null;
+});
+
+async function startSandbox({ assets = [LRU_ASSET], env = {} } = {}) {
+ sandbox = sandboxHome();
+ Object.assign(sandbox.env, env);
+ proxy = await fakeProxy({ assets });
+ writeProxySettings(sandbox.home, { url: proxy.url });
+}
+
+function session(id) {
+ const cwd = join(sandbox.home, 'project');
+ mkdirSync(cwd, { recursive: true });
+ const base = { conversation_id: id, session_id: id, model: 'claude-4.5-sonnet', workspace_roots: [cwd], cursor_version: '3.27.6' };
+ let turn = 0;
+ const generation = () => `${id}-gen-${turn}`;
+ const hook = (name, event, fields) => runHook(name, { ...base, generation_id: generation(), hook_event_name: event, ...fields }, sandbox.env);
+ const tool = (name, input) => ({ tool_name: name, tool_input: input, tool_use_id: `call-${Math.random()}`, cwd });
+ return {
+ cwd,
+ generation,
+ start: () => runHook('session-start', { ...base, generation_id: '', hook_event_name: 'sessionStart', is_background_agent: false, composer_mode: 'agent' }, sandbox.env),
+ prompt: (prompt, { sameTurn = false, composerMode = 'agent' } = {}) => {
+ if (!sameTurn) turn += 1;
+ return hook('user-prompt-submit', 'beforeSubmitPrompt', { prompt, attachments: [], ...(composerMode && { composer_mode: composerMode }) });
+ },
+ shell: (command, output, exitCode = 0) => hook('post-tool-use', 'postToolUse', {
+ ...tool('Shell', { command, cwd }), tool_output: JSON.stringify({ output, exitCode }), duration: 12,
+ }),
+ shellFailed: (command, message, failureType = 'error', interrupted = false) => hook('post-tool-use-failure', 'postToolUseFailure', {
+ ...tool('Shell', { command, cwd }), error_message: message, failure_type: failureType, is_interrupt: interrupted, duration: 12,
+ }),
+ write: async (file, content) => {
+ const path = join(cwd, file);
+ await hook('pre-tool-use', 'preToolUse', tool('Write', { file_path: path, content }));
+ writeFileSync(path, content);
+ await hook('post-tool-use', 'postToolUse', { ...tool('Write', { file_path: path, content }), tool_output: JSON.stringify({ file_path: path, success: true }) });
+ await hook('after-file-edit', 'afterFileEdit', { file_path: path, edits: [{ old_string: '', new_string: content }] });
+ },
+ answer: (text) => hook('after-agent-response', 'afterAgentResponse', { text }),
+ stop: (status = 'completed') => hook('stop', 'stop', { status, loop_count: 0 }),
+ finish: async (text, status = 'completed') => {
+ if (status === 'completed') await hook('after-agent-response', 'afterAgentResponse', { text });
+ return hook('stop', 'stop', { status, loop_count: 0 });
+ },
+ end: (reason = 'user_close') => hook('session-end', 'sessionEnd', { reason, duration_ms: 1000, is_background_agent: false, final_status: 'completed' }),
+ };
+}
+
+const statePath = (name) => join(sandbox.home, '.evolver', 'state', name);
+const candidates = () => readJson(statePath('cursor-distill-pending.json'), {});
+const agentLog = () => join(sandbox.home, 'fake-cursor-agent.log');
+const agentCalls = () => readFileSync(agentLog(), 'utf8').trim().split('\n').map((line) => JSON.parse(line));
+const reuseReports = () => proxy.of('/asset/reuse-result');
+
+async function lruTurn(s) {
+ const injected = await s.prompt(LRU_PROMPT);
+ await s.write('lru-cache.js', LRU_SOURCE);
+ await s.shell('node --test', FAILED_RUN, 1);
+ await s.shell('node --test 2>&1 | tail -5', PASSED_RUN);
+ await s.finish('LRU 缓存写好了,测试通过。');
+ return injected;
+}
+
+async function verifiedTurn(s, prompt = 'add a retry to the uploader') {
+ await s.prompt(prompt);
+ await s.shell('npm test', PASSED_RUN);
+ await s.finish('Retries added; npm test passes.');
+}
+
+test('a recalled strategy rides the prompt as additional_context and ends with "Apply them where they fit."', async () => {
+ await startSandbox();
+ const output = await session('s-recall').prompt(LRU_PROMPT);
+
+ assert.equal(output.continue, true);
+ assert.equal(output.additional_context, [
+ '[Evolution Memory] LRU cache in Node.js (EvoMap network):',
+ ...LRU_ASSET.strategy.map((step, index) => `${index + 1}. ${step}`),
+ '',
+ 'Apply them where they fit.',
+ ].join('\n'));
+ assert.doesNotMatch(output.additional_context, /evolver_/);
+ assert.deepEqual(proxy.of('/asset/fetch'), [{ text: LRU_PROMPT }]);
+});
+
+test('an inline edit, which names no composer_mode and drops added context, is neither recalled for nor recorded', async () => {
+ await startSandbox();
+ const s = session('s-inline');
+ assert.deepEqual(await s.prompt(LRU_PROMPT, { composerMode: null }), {});
+ await s.stop();
+ assert.deepEqual(proxy.of('/asset/fetch'), []);
+ assert.equal(existsSync(statePath('cursor-reports-queue.jsonl')), false);
+});
+
+test('session start adds nothing to the context when Evolver is set up', async () => {
+ await startSandbox();
+ assert.deepEqual(await session('s-quiet').start(), {});
+});
+
+test("the turn end reports the strategy with the turn's own evidence and leaves no candidate for a reused one", async () => {
+ await startSandbox();
+ const s = session('s-lru');
+ await lruTurn(s);
+
+ const [report] = await waitFor(() => reuseReports());
+ assert.deepEqual(report, {
+ asset_id: ASSET_ID,
+ outcome: 'success',
+ task_id: 'cursor:s-lru:1',
+ reason: 'Injected by Evolver into Cursor turn 1; applied: used; last test passed after failing earlier.',
+ evidence: { tier: 'L1', applied: 'used', check: { kind: 'test', passed: true, fail_then_pass: true }, aborted: false, stage: 'immediate' },
+ });
+ assert.deepEqual(candidates(), {}, 'asset_reused');
+});
+
+test('a non-zero exit fails a check even when its output reads clean, and postToolUseFailure fails it too', async () => {
+ await startSandbox();
+ const s = session('s-exit');
+ await s.prompt(LRU_PROMPT);
+ await s.shell('npm test', 'done', 2);
+ await s.finish('done');
+ const [first] = await waitFor(() => reuseReports());
+ assert.deepEqual(first.evidence.check, { kind: 'test', passed: false, fail_then_pass: false });
+
+ const t = session('s-timeout');
+ await t.prompt(LRU_PROMPT);
+ await t.shellFailed('npm test', 'Command timed out after 30000ms', 'timeout');
+ await t.finish('done');
+ const second = await waitFor(() => reuseReports().find((report) => report.task_id === 'cursor:s-timeout:1'));
+ assert.equal(second.evidence.check.passed, false);
+});
+
+test('a check the person interrupted or a command that was denied is no evidence', async () => {
+ await startSandbox();
+ const s = session('s-cut-check');
+ await s.prompt(LRU_PROMPT);
+ await s.shellFailed('npm test', 'Command was aborted', 'error', true);
+ await s.shellFailed('npm test', 'denied', 'permission_denied');
+ await s.finish('done');
+ const [report] = await waitFor(() => reuseReports());
+ assert.equal(report.evidence.check, undefined);
+ assert.match(report.reason, /no check ran/);
+});
+
+test('a correction in the next prompt sends an L3 follow-up under :correction', async () => {
+ await startSandbox();
+ const s = session('s-fix');
+ await lruTurn(s);
+ await s.prompt('还是报错,不对');
+ const correction = await waitFor(() => reuseReports().find((report) => report.task_id.endsWith(':correction')));
+ assert.deepEqual(correction, {
+ asset_id: ASSET_ID,
+ outcome: 'failed',
+ task_id: 'cursor:s-fix:1:correction',
+ reason: "The person's next prompt read as correction of the turn that reused this strategy.",
+ evidence: { tier: 'L3', applied: 'used', stage: 'correction' },
+ });
+});
+
+test('the next session in the same project reports whether the edits were kept, under :retention', async () => {
+ await startSandbox();
+ await lruTurn(session('s-first'));
+ await waitFor(() => reuseReports());
+ await session('s-second').start();
+ const retention = await waitFor(() => reuseReports().find((report) => report.task_id.endsWith(':retention')));
+ assert.equal(retention.task_id, 'cursor:s-first:1:retention');
+ assert.equal(retention.outcome, 'success');
+});
+
+test('each stop status is the turn-end reason: aborted reports aborted, error reports failed, neither leaves a candidate', async () => {
+ await startSandbox();
+ const cut = session('s-cut');
+ await cut.prompt(LRU_PROMPT);
+ await cut.shell('node --test', PASSED_RUN);
+ await cut.stop('aborted');
+ const aborted = await waitFor(() => reuseReports().find((report) => report.task_id === 'cursor:s-cut:1'));
+ assert.equal(aborted.outcome, 'success', 'the check that passed still decides the outcome');
+ assert.equal(aborted.evidence.aborted, true);
+ assert.deepEqual(aborted.evidence.check, { kind: 'test', passed: true, fail_then_pass: false });
+
+ const failed = session('s-failed');
+ await failed.prompt(LRU_PROMPT);
+ await failed.stop('error');
+ const errored = await waitFor(() => reuseReports().find((report) => report.task_id === 'cursor:s-failed:1'));
+ assert.equal(errored.outcome, 'failed');
+ assert.equal(errored.evidence.aborted, false);
+ assert.deepEqual(candidates(), {});
+});
+
+test('a chat closed while its turn runs reports the strategy aborted, with an empty record, once', async () => {
+ await startSandbox();
+ const s = session('s-gone');
+ await s.prompt(LRU_PROMPT);
+ await s.end('window_close');
+ await s.stop('aborted');
+ const [report] = await waitFor(() => reuseReports());
+ assert.deepEqual(report, {
+ asset_id: ASSET_ID,
+ outcome: 'failed',
+ task_id: 'cursor:s-gone:1',
+ reason: 'Injected by Evolver into Cursor turn 1; applied: unknown; no check ran.',
+ evidence: { tier: 'none', applied: 'partial', aborted: true, stage: 'immediate' },
+ });
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ assert.equal(reuseReports().length, 1);
+});
+
+test('a turn whose stop never came is closed as an error when the next prompt arrives', async () => {
+ await startSandbox();
+ const s = session('s-error');
+ await s.prompt(LRU_PROMPT);
+ await s.prompt('continue with the README');
+ const [report] = await waitFor(() => reuseReports());
+ assert.equal(report.task_id, 'cursor:s-error:1');
+ assert.equal(report.outcome, 'failed');
+ assert.equal(report.evidence.aborted, false);
+});
+
+test("a late stop of an earlier turn closes nothing of the current one, and a repeated prompt opens no new turn", async () => {
+ await startSandbox();
+ const s = session('s-late');
+ await s.prompt(LRU_PROMPT);
+ const firstGeneration = s.generation();
+ await s.prompt('also add a size getter please');
+ await runHook('stop', { conversation_id: 's-late', generation_id: firstGeneration, hook_event_name: 'stop', status: 'completed', model: 'm' }, sandbox.env);
+ assert.deepEqual(await s.prompt('also add a size getter please', { sameTurn: true }), {});
+ await s.finish('done');
+ await waitFor(() => reuseReports().length >= 1);
+ assert.deepEqual(reuseReports().map((report) => report.task_id), ['cursor:s-late:1']);
+ assert.equal(reuseReports()[0].outcome, 'failed', 'turn 1 never had its stop in time and closed as an error');
+});
+
+test('a reopened chat keeps counting turns after its session end, so task ids never repeat', async () => {
+ await startSandbox();
+ const s = session('s-resume');
+ await s.prompt(LRU_PROMPT);
+ await s.finish('done');
+ await s.end();
+ await s.prompt('用 Node 再写一个 LRU cache 的测试,覆盖淘汰顺序');
+ await s.finish('done again');
+ const reports = await waitFor(() => (reuseReports().length >= 2 ? reuseReports() : null));
+ assert.deepEqual(reports.map((report) => report.task_id).sort(), ['cursor:s-resume:1', 'cursor:s-resume:2']);
+});
+
+test('every hook answers a JSON object on stdout', async () => {
+ await startSandbox({ assets: [] });
+ const s = session('s-json');
+ assert.deepEqual(await s.prompt('add a retry to the uploader'), {});
+ assert.deepEqual(await s.shell('npm test', PASSED_RUN), {});
+ assert.deepEqual(await s.answer('done'), {});
+ assert.deepEqual(await s.stop(), {});
+ assert.deepEqual(await s.end(), {});
+});
+
+test("a killed session's open strategies are reported aborted at the next session start", async () => {
+ await startSandbox();
+ mkdirSync(statePath(''), { recursive: true });
+ writeFileSync(statePath('cursor-injected-0000000000000000.json'), JSON.stringify({
+ 'sha256:orphan': { turn: 4, injectedAt: Date.now(), strategyText: '1. a.', sessionId: 's-killed', pid: deadPid() },
+ }));
+ writeFileSync(statePath('codex-injected-0000000000000000.json'), JSON.stringify({
+ 'sha256:other-host': { turn: 1, injectedAt: Date.now(), strategyText: '1. a.', sessionId: 's-codex', pid: deadPid() },
+ }));
+ await session('s-next').start();
+ const [report] = await waitFor(() => reuseReports());
+ assert.equal(report.asset_id, 'sha256:orphan');
+ assert.equal(report.task_id, 'cursor:s-killed:4');
+ assert.deepEqual(report.evidence, { tier: 'none', applied: 'partial', aborted: true, stage: 'immediate' });
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ assert.equal(reuseReports().length, 1, "another host's ledger is left to it");
+});
+
+test('a verified turn becomes the candidate; a failed check leaves none, a later one replaces it, and a correction drops it', async () => {
+ await startSandbox({ assets: [] });
+ const s = session('s-candidate');
+ await s.prompt('fix the flaky upload');
+ await s.shell('npm test', FAILED_RUN, 1);
+ await s.finish('Still failing.');
+ assert.deepEqual(candidates(), {}, 'failed_check');
+
+ await s.prompt('explain the retry policy');
+ await s.finish('It retries three times.');
+ assert.deepEqual(candidates(), {}, 'no_tool_use');
+
+ await verifiedTurn(s);
+ const candidate = candidates()['s-candidate'];
+ assert.equal(candidate.prompt, 'add a retry to the uploader');
+ assert.deepEqual(candidate.commands, ['npm test']);
+ assert.equal(candidate.answer, 'Retries added; npm test passes.');
+ assert.deepEqual(candidate.model, { model: 'claude-4.5-sonnet' });
+ assert.equal(candidate.turn, 3);
+
+ await verifiedTurn(s, 'add jitter to the backoff');
+ assert.equal(candidates()['s-candidate'].turn, 4);
+
+ await s.prompt('不对,这样不行');
+ assert.deepEqual(candidates(), {});
+});
+
+test('a question that only asks for an opinion keeps the candidate', async () => {
+ await startSandbox({ assets: [] });
+ const s = session('s-ask');
+ await verifiedTurn(s);
+ await s.prompt('这样不对吧?');
+ assert.ok(candidates()['s-ask']);
+});
+
+test('the session end distills its candidate once through cursor-agent, headless and outside the conversation', async () => {
+ await startSandbox({ assets: [] });
+ const s = session('s-distill');
+ await verifiedTurn(s);
+ assert.equal(proxy.of('/conversation/distill').length, 0, 'nothing runs at turn end');
+ await s.end();
+
+ const [body] = await waitFor(() => proxy.of('/conversation/distill'));
+ assert.equal(body.platform, 'cursor');
+ assert.equal(body.trigger, 'session_end');
+ assert.equal(body.user_prompt, 'add a retry to the uploader');
+ assert.equal(body.final_answer, 'Retries added; npm test passes.');
+ assert.deepEqual(body.strategy, ['Measure.', 'Back off.']);
+ assert.deepEqual(body.execution, { trace: [{ command: 'npm test', exit: 0 }] });
+ assert.equal(body.persist, true);
+ assert.equal(body.publish, false);
+ assert.equal(body.distill_usage, undefined, 'the CLI reports no token usage');
+ await waitFor(() => Object.keys(candidates()).length === 0);
+
+ const [call] = agentCalls();
+ assert.equal(call.headless, '1');
+ assert.deepEqual(call.args.slice(0, 6), ['-p', '--output-format', 'json', '--mode', 'ask', '--trust']);
+ assert.deepEqual(call.args.slice(call.args.indexOf('--model'), call.args.indexOf('--model') + 2), ['--model', 'claude-4.5-sonnet']);
+ assert.match(call.args.at(-1), /## Task\nadd a retry to the uploader/);
+ assert.equal(call.args.includes('--force'), false);
+});
+
+test('a failed or unparsed distillation keeps the candidate for the next session start, which claims and distills it', async () => {
+ await startSandbox({ assets: [], env: { FAKE_AGENT_ANSWER: 'not json' } });
+ const s = session('s-retry');
+ await verifiedTurn(s);
+ await s.end();
+ await waitFor(() => candidates()['s-retry']?.attempts === 1);
+ assert.equal(candidates()['s-retry'].claimedBy, null);
+ assert.equal(candidates()['s-retry'].closed, true);
+
+ delete sandbox.env.FAKE_AGENT_ANSWER;
+ await session('s-after').start();
+ await waitFor(() => proxy.of('/conversation/distill'));
+ await waitFor(() => Object.keys(candidates()).length === 0);
+});
+
+test("a killed session's candidate is claimed and distilled by the next session start; an idle one is left alone", async () => {
+ await startSandbox({ assets: [] });
+ const candidate = (pid, at) => ({ prompt: 'p', commands: ['npm test'], paths: [], checks: [], answer: 'a', model: { model: 'm' }, turn: 1, pid, attempts: 0, at });
+ mkdirSync(statePath(''), { recursive: true });
+ writeFileSync(statePath('cursor-distill-pending.json'), JSON.stringify({ 's-killed': candidate(deadPid(), 1), 's-idle': candidate(process.pid, 2) }));
+ writeFileSync(statePath('codex-distill-pending.json'), JSON.stringify({ 's-codex': candidate(deadPid(), 3) }));
+
+ await session('s-next').start();
+ await waitFor(() => proxy.of('/conversation/distill'));
+ await waitFor(() => !candidates()['s-killed']);
+ assert.ok(candidates()['s-idle']);
+ assert.equal(proxy.of('/conversation/distill').length, 1);
+ assert.ok(readJson(statePath('codex-distill-pending.json'), {})['s-codex'], "another host's candidate is left to it");
+});
+
+test('inside the headless distillation every hook does nothing: no recall, no turn, no worker', async () => {
+ await startSandbox({ env: { EVOLVER_HEADLESS: '1' } });
+ const s = session('s-headless');
+ assert.deepEqual(await s.start(), {});
+ assert.deepEqual(await s.prompt(LRU_PROMPT), {});
+ await s.shell('npm test', PASSED_RUN);
+ await s.finish('done');
+ await s.end();
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ assert.deepEqual(proxy.requests, []);
+ assert.equal(existsSync(statePath('')), false);
+});
+
+test('a typed plugin command is answered by the hook and stops the prompt before any model sees it', async () => {
+ await startSandbox();
+ const s = session('s-commands');
+ const status = await s.prompt('/evolver-status');
+ assert.equal(status.continue, false);
+ assert.match(status.user_message, /^Evolver Cursor plugin status/);
+
+ const search = await s.prompt('/evolver-search "flaky upload" retries');
+ assert.equal(search.continue, false);
+ assert.match(search.user_message, /Retry with backoff/);
+ assert.deepEqual(proxy.of('/asset/search'), [{ text: 'flaky upload retries', limit: 5 }]);
+
+ assert.match((await s.prompt('/evolver-distill')).user_message, /Nothing to distill yet/);
+ assert.deepEqual(proxy.of('/asset/fetch'), [], 'a command turn recalls nothing');
+});
+
+test('/evolver-distill distills this chat now through cursor-agent', async () => {
+ await startSandbox({ assets: [] });
+ const s = session('s-now');
+ await verifiedTurn(s);
+ const answer = await s.prompt('/evolver-distill the backoff');
+ assert.equal(answer.continue, false);
+ assert.match(answer.user_message, /gene_test/);
+ const [body] = proxy.of('/conversation/distill');
+ assert.equal(body.user_prompt, 'add a retry to the uploader');
+ assert.match(agentCalls()[0].args.at(-1), /Focus on: the backoff/);
+ assert.deepEqual(candidates(), {});
+});
+
+test('/evolver-evolve is a prompt for the agent, recalled for like any other, and leaves the previous turn to be labelled', async () => {
+ await startSandbox();
+ const s = session('s-evolve');
+ await lruTurn(s);
+ await waitFor(() => reuseReports());
+ await s.prompt('/evolver-evolve 不对的地方在哪里');
+ assert.equal(proxy.of('/asset/fetch').length, 2);
+ await s.finish('Reflected.');
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ assert.equal(reuseReports().some((report) => report.task_id.endsWith(':correction')), false, 'a command is no verdict');
+ await s.prompt('还是报错,不对');
+ const correction = await waitFor(() => reuseReports().find((report) => report.task_id.endsWith(':correction')));
+ assert.equal(correction.task_id, 'cursor:s-evolve:1:correction');
+});
+
+test('a prompt typed while the stop of the turn it aborted still runs waits for it: notes, candidate and follow-up all land', async () => {
+ await startSandbox();
+ const s = session('s-race');
+ await s.prompt(LRU_PROMPT);
+ await s.write('lru-cache.js', LRU_SOURCE);
+ await s.shell('node --test', PASSED_RUN);
+ await s.answer('LRU 缓存写好了。');
+ await Promise.all([s.stop('completed'), s.prompt('还是报错,不对')]);
+ const reports = await waitFor(() => (reuseReports().length >= 2 ? reuseReports() : null));
+ const immediate = reports.find((report) => report.task_id === 'cursor:s-race:1');
+ assert.equal(immediate.evidence.tier, 'L1', "the stop took the turn's notes");
+ assert.ok(reports.find((report) => report.task_id === 'cursor:s-race:1:correction'));
+});
+
+test('a verified turn whose stop races a correction leaves no candidate behind', async () => {
+ await startSandbox({ assets: [] });
+ const s = session('s-race-candidate');
+ await s.prompt('add a retry to the uploader');
+ await s.shell('npm test', PASSED_RUN);
+ await s.answer('Retries added.');
+ await Promise.all([s.stop('completed'), s.prompt('不对,这样不行')]);
+ assert.deepEqual(candidates(), {});
+});
+
+test('a stop that arrives after its chat closed writes nothing', async () => {
+ await startSandbox({ assets: [] });
+ const s = session('s-late-stop');
+ await s.prompt('add a retry to the uploader');
+ await s.shell('npm test', PASSED_RUN);
+ await s.answer('Retries added.');
+ await s.end();
+ await s.stop('completed');
+ assert.deepEqual(candidates(), {});
+});
+
+test('the chat on its default model, which Cursor names with an empty string, is distilled on the CLI default', async () => {
+ await startSandbox({ assets: [] });
+ const s = session('s-default');
+ await s.prompt('add a retry to the uploader');
+ await s.shell('npm test', PASSED_RUN);
+ await s.answer('Retries added.');
+ await runHook('stop', { conversation_id: 's-default', generation_id: s.generation(), hook_event_name: 'stop', status: 'completed', model: '' }, sandbox.env);
+ assert.deepEqual(candidates()['s-default'].model, { model: 'default' });
+ await s.end();
+ await waitFor(() => proxy.of('/conversation/distill'));
+ assert.equal(agentCalls()[0].args.includes('--model'), false);
+});
+
+test('a backgrounded command, which Cursor fails with "Unknown error", has no verdict yet', async () => {
+ await startSandbox();
+ const s = session('s-bg');
+ await s.prompt(LRU_PROMPT);
+ await s.shellFailed('npm test', 'Unknown error');
+ await s.finish('Started the suite in the background.');
+ const [report] = await waitFor(() => reuseReports());
+ assert.equal(report.evidence.check, undefined);
+});
+
+test('a plugin command whose CLI cannot run answers its error in the chat instead of reaching the model', async () => {
+ await startSandbox({ env: { PATH: '/nonexistent' } });
+ const answer = await session('s-broken').prompt('/evolver-sync');
+ assert.equal(answer.continue, false);
+ assert.match(answer.user_message, /\S/);
+});
diff --git a/test/injected-assets.test.mjs b/test/injected-assets.test.mjs
new file mode 100644
index 0000000..7484b65
--- /dev/null
+++ b/test/injected-assets.test.mjs
@@ -0,0 +1,42 @@
+import assert from 'node:assert/strict';
+import { afterEach, test } from 'node:test';
+
+import { queueOrphanedUnreported, rememberInjected } from '../lib/injected-assets.mjs';
+import { freshHome } from './helpers/sandbox.mjs';
+
+let restoreHome = null;
+
+afterEach(() => {
+ restoreHome?.();
+ restoreHome = null;
+});
+
+const isGone = () => true;
+
+function orphanedNow() {
+ const taken = [];
+ queueOrphanedUnreported((due) => taken.push(...due), { isGone });
+ return taken.map((asset) => asset.assetId);
+}
+
+test('an orphaned injection whose queueing fails stays unreported for the next session start', () => {
+ restoreHome = freshHome();
+ rememberInjected('s-killed', 'sha256:a', { pid: 1, turn: 2, strategyText: '1. a.' });
+
+ assert.throws(() => queueOrphanedUnreported(() => {
+ throw new Error('disk full');
+ }, { isGone }));
+
+ assert.deepEqual(orphanedNow(), ['sha256:a']);
+ assert.deepEqual(orphanedNow(), []);
+});
+
+test('an injection of a live session is not taken as orphaned', () => {
+ restoreHome = freshHome();
+ rememberInjected('s-idle', 'sha256:b', { pid: process.pid, turn: 1, strategyText: '1. b.' });
+
+ const taken = [];
+ queueOrphanedUnreported((due) => taken.push(...due), { isGone: () => false });
+
+ assert.deepEqual(taken, []);
+});
diff --git a/test/manifest.test.js b/test/manifest.test.js
deleted file mode 100644
index 0420e73..0000000
--- a/test/manifest.test.js
+++ /dev/null
@@ -1,110 +0,0 @@
-'use strict';
-
-const { test } = require('node:test');
-const assert = require('node:assert/strict');
-const fs = require('node:fs');
-const os = require('node:os');
-const path = require('node:path');
-const { spawn } = require('node:child_process');
-
-const ROOT = path.join(__dirname, '..');
-
-function read(rel) {
- return fs.readFileSync(path.join(ROOT, rel), 'utf8');
-}
-
-function readJson(rel) {
- return JSON.parse(read(rel));
-}
-
-test('plugin.json is v2.1.0 with optional Configure variables', () => {
- const manifest = readJson('.cursor-plugin/plugin.json');
- assert.equal(manifest.version, '2.1.0');
- assert.equal(manifest.variables.type, 'object');
- const props = manifest.variables.properties;
- for (const key of [
- 'EVOMAP_NODE_ID',
- 'EVOMAP_HUB_URL',
- 'EVOMAP_PROXY_PORT',
- 'EVOLVE_STRATEGY',
- ]) {
- assert.ok(props[key], `missing variable ${key}`);
- }
- assert.ok(
- !manifest.variables.required || manifest.variables.required.length === 0,
- 'Configure fields must be optional so a blank Node ID still loads'
- );
-});
-
-test('marketplace.json points at the repo root and matches version', () => {
- const market = readJson('.cursor-plugin/marketplace.json');
- assert.equal(market.metadata.version, '2.1.0');
- assert.equal(market.plugins[0].source, '.');
- assert.equal(market.plugins[0].version, '2.1.0');
-});
-
-test('mcp.json env placeholders match plugin variables', () => {
- const mcp = readJson('mcp.json');
- const env = mcp.mcpServers['evolver-proxy'].env;
- assert.equal(env.EVOMAP_PROXY_PORT, '${EVOMAP_PROXY_PORT}');
- assert.equal(env.EVOMAP_HUB_URL, '${EVOMAP_HUB_URL}');
- assert.equal(env.EVOMAP_NODE_ID, '${EVOMAP_NODE_ID}');
- assert.equal(env.EVOLVE_STRATEGY, '${EVOLVE_STRATEGY}');
-});
-
-test('hooks.json records on both stop and sessionEnd', () => {
- const hooks = readJson('hooks/hooks.json');
- assert.ok(hooks.hooks.stop);
- assert.ok(hooks.hooks.sessionEnd);
-});
-
-test('session-start source never tells the agent to run git init', () => {
- const src = read('hooks/session-start.js');
- assert.equal(src.includes('git init'), false);
-});
-
-test('hooks do not spawn git by literal argv0', () => {
- const files = [
- 'hooks/_paths.js',
- 'hooks/session-start.js',
- 'hooks/session-end.js',
- 'hooks/signal-detect.js',
- ];
- for (const rel of files) {
- const src = read(rel);
- assert.equal(
- /spawnSync\(\s*['"]git['"]/.test(src),
- false,
- `${rel} must not spawnSync('git', ...)`
- );
- }
-});
-
-test('session-start on a non-git folder emits the nongit notice, not git init', async () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-ss-'));
- const state = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-ss-state-'));
- const proc = spawn('node', [path.join(ROOT, 'hooks', 'session-start.js')], {
- cwd: dir,
- env: Object.assign({}, process.env, {
- CURSOR_PROJECT_DIR: dir,
- EVOLVER_SESSION_STATE_DIR: state,
- EVOLVER_GIT_BINARY: '',
- }),
- stdio: ['pipe', 'pipe', 'pipe'],
- });
- let stdout = '';
- proc.stdout.on('data', (c) => {
- stdout += c.toString('utf8');
- });
- proc.stderr.on('data', () => {});
- proc.stdin.end('{}\n');
- const status = await new Promise((resolve) => {
- proc.on('close', resolve);
- });
- assert.equal(status, 0);
- const parsed = JSON.parse(stdout);
- assert.match(parsed.additionalContext || '', /not a git repository/);
- assert.equal((parsed.additionalContext || '').includes('git init'), false);
- fs.rmSync(dir, { recursive: true, force: true });
- fs.rmSync(state, { recursive: true, force: true });
-});
diff --git a/test/manifest.test.mjs b/test/manifest.test.mjs
new file mode 100644
index 0000000..1e820a8
--- /dev/null
+++ b/test/manifest.test.mjs
@@ -0,0 +1,43 @@
+import assert from 'node:assert/strict';
+import { existsSync, readFileSync } from 'node:fs';
+import { test } from 'node:test';
+
+const root = new URL('../', import.meta.url);
+const readJson = (path) => JSON.parse(readFileSync(new URL(path, root), 'utf8'));
+
+// Cursor 3.27.6 rejects a whole hooks file that names one event outside this list.
+const CURSOR_HOOK_EVENTS = new Set([
+ 'beforeShellExecution', 'beforeMCPExecution', 'afterShellExecution', 'afterMCPExecution', 'beforeReadFile', 'afterFileEdit',
+ 'beforeTabFileRead', 'afterTabFileEdit', 'stop', 'beforeSubmitPrompt', 'afterAgentResponse', 'afterAgentThought', 'sessionStart',
+ 'sessionEnd', 'preCompact', 'subagentStart', 'subagentStop', 'preToolUse', 'postToolUse', 'postToolUseFailure', 'workspaceOpen',
+]);
+
+test('every manifest carries the same minor-bumped version', () => {
+ const plugin = readJson('.cursor-plugin/plugin.json');
+ const market = readJson('.cursor-plugin/marketplace.json');
+ assert.equal(plugin.version, '2.2.0');
+ assert.equal(market.metadata.version, '2.2.0');
+ assert.equal(market.plugins[0].version, '2.2.0');
+ assert.equal(readJson('package.json').version, '2.2.0');
+ assert.equal(market.plugins[0].source, '.');
+});
+
+test('the plugin ships hooks and commands only: no MCP server, skill, rule or tool reaches the model', () => {
+ const plugin = readJson('.cursor-plugin/plugin.json');
+ assert.deepEqual(Object.keys(plugin).filter((key) => ['skills', 'rules', 'agents', 'mcpServers', 'variables'].includes(key)), []);
+ for (const path of ['mcp.json', 'skills', 'rules', 'agents']) assert.equal(existsSync(new URL(path, root)), false, path);
+});
+
+test('hooks.json is version 1, names only events Cursor knows, and every hook script exists', () => {
+ const { version, hooks } = readJson('hooks/hooks.json');
+ assert.equal(version, 1);
+ for (const [event, scripts] of Object.entries(hooks)) {
+ assert.ok(CURSOR_HOOK_EVENTS.has(event), event);
+ for (const { command, timeout } of scripts) {
+ const script = /\$\{CURSOR_PLUGIN_ROOT\}\/(hooks\/[\w-]+\.mjs)/.exec(command)[1];
+ assert.ok(existsSync(new URL(script, root)), script);
+ assert.ok(timeout > 0);
+ }
+ }
+ assert.ok(hooks.beforeSubmitPrompt[0].timeout >= 600, 'a plugin command may run the CLI for ten minutes');
+});
diff --git a/test/mcp-bridge.test.js b/test/mcp-bridge.test.js
deleted file mode 100644
index e8526e1..0000000
--- a/test/mcp-bridge.test.js
+++ /dev/null
@@ -1,133 +0,0 @@
-'use strict';
-
-const { test } = require('node:test');
-const assert = require('node:assert/strict');
-const { spawn } = require('node:child_process');
-const path = require('node:path');
-
-const BRIDGE = path.join(__dirname, '..', 'mcp', 'evolver-proxy.mjs');
-
-function startBridge() {
- const proc = spawn('node', [BRIDGE], {
- stdio: ['pipe', 'pipe', 'pipe'],
- });
- proc.stderr.on('data', () => {});
- return proc;
-}
-
-function rpc(proc, msg, timeoutMs) {
- const limit = timeoutMs || 4000;
- return new Promise((resolve, reject) => {
- const timer = setTimeout(() => {
- proc.stdout.off('data', onData);
- reject(new Error(`RPC timeout waiting for id=${msg.id}`));
- }, limit);
- let buf = '';
- function onData(chunk) {
- buf += chunk.toString('utf8');
- const lines = buf.split('\n');
- buf = lines.pop();
- for (const line of lines) {
- if (!line.trim()) continue;
- let parsed;
- try {
- parsed = JSON.parse(line);
- } catch (_err) {
- continue;
- }
- if (parsed && parsed.id === msg.id) {
- clearTimeout(timer);
- proc.stdout.off('data', onData);
- resolve(parsed);
- }
- }
- }
- proc.stdout.on('data', onData);
- proc.stdin.write(`${JSON.stringify(msg)}\n`);
- });
-}
-
-test('MCP bridge ignores unexpanded ${EVOMAP_PROXY_PORT} placeholders', async () => {
- const proc = spawn('node', [BRIDGE], {
- stdio: ['pipe', 'pipe', 'pipe'],
- env: Object.assign({}, process.env, {
- EVOMAP_PROXY_PORT: '${EVOMAP_PROXY_PORT}',
- }),
- });
- let stderr = '';
- proc.stderr.on('data', (c) => {
- stderr += c.toString('utf8');
- });
- try {
- await rpc(proc, {
- jsonrpc: '2.0',
- id: 1,
- method: 'initialize',
- params: {
- protocolVersion: '2025-06-18',
- capabilities: {},
- clientInfo: { name: 'test', version: '0' },
- },
- });
- assert.match(stderr, /127\.0\.0\.1:19820/);
- assert.equal(stderr.includes('${EVOMAP_PROXY_PORT}'), false);
- } finally {
- proc.kill('SIGTERM');
- }
-});
-
-test('MCP bridge lists query search and report_reuse, rejects empty search', async () => {
- const proc = startBridge();
- try {
- await rpc(proc, {
- jsonrpc: '2.0',
- id: 1,
- method: 'initialize',
- params: {
- protocolVersion: '2025-06-18',
- capabilities: {},
- clientInfo: { name: 'test', version: '0' },
- },
- });
- const listed = await rpc(proc, {
- jsonrpc: '2.0',
- id: 2,
- method: 'tools/list',
- });
- const tools = listed.result && listed.result.tools;
- assert.ok(Array.isArray(tools));
- const names = tools.map((t) => t.name);
- assert.ok(names.includes('evolver_report_reuse'));
- assert.ok(names.includes('evolver_search_assets'));
- assert.ok(names.includes('evolver_recipe_search'));
- assert.ok(names.includes('evolver_recipe_express'));
- const recipeIdx = names.indexOf('evolver_recipe_search');
- const fallbackIdx = names.indexOf('evolver_search_assets');
- assert.ok(recipeIdx >= 0 && recipeIdx < fallbackIdx);
- const search = tools.find((t) => t.name === 'evolver_search_assets');
- assert.match(search.description, /Fallback/);
- assert.ok(search.inputSchema.properties.query);
- assert.ok(
- !search.inputSchema.required ||
- !search.inputSchema.required.includes('signals')
- );
- const empty = await rpc(proc, {
- jsonrpc: '2.0',
- id: 3,
- method: 'tools/call',
- params: { name: 'evolver_search_assets', arguments: {} },
- });
- assert.equal(empty.result.isError, true);
- assert.match(empty.result.content[0].text, /query/);
- const missingRecipe = await rpc(proc, {
- jsonrpc: '2.0',
- id: 4,
- method: 'tools/call',
- params: { name: 'evolver_recipe_express', arguments: {} },
- });
- assert.equal(missingRecipe.result.isError, true);
- assert.match(missingRecipe.result.content[0].text, /recipeId/);
- } finally {
- proc.kill('SIGTERM');
- }
-});
diff --git a/test/paths.test.js b/test/paths.test.js
deleted file mode 100644
index 9cdd236..0000000
--- a/test/paths.test.js
+++ /dev/null
@@ -1,98 +0,0 @@
-'use strict';
-
-const { test, beforeEach, afterEach } = require('node:test');
-const assert = require('node:assert/strict');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-
-const paths = require('../hooks/_paths');
-
-const ORIGINAL_GIT = process.env.EVOLVER_GIT_BINARY;
-
-beforeEach(() => {
- paths._resetGitBinaryCache();
-});
-
-afterEach(() => {
- if (ORIGINAL_GIT === undefined) {
- delete process.env.EVOLVER_GIT_BINARY;
- } else {
- process.env.EVOLVER_GIT_BINARY = ORIGINAL_GIT;
- }
- paths._resetGitBinaryCache();
-});
-
-test('isGitWorkspace is filesystem-only: empty dir is not a repo', () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-nongit-'));
- assert.equal(paths.isGitWorkspace(dir), false);
- fs.rmSync(dir, { recursive: true, force: true });
-});
-
-test('isGitWorkspace treats a .git directory as a repo without spawning git', () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-gitdir-'));
- fs.mkdirSync(path.join(dir, '.git'));
- process.env.EVOLVER_GIT_BINARY = '';
- paths._resetGitBinaryCache();
- assert.equal(paths.isGitWorkspace(dir), true);
- fs.rmSync(dir, { recursive: true, force: true });
-});
-
-test('gitUsability: repo without a usable git binary is not usable', () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-nousable-'));
- fs.mkdirSync(path.join(dir, '.git'));
- process.env.EVOLVER_GIT_BINARY = '';
- paths._resetGitBinaryCache();
- const u = paths.gitUsability(dir);
- assert.equal(u.hasRepo, true);
- assert.equal(u.gitBinary, null);
- assert.equal(u.usable, false);
- fs.rmSync(dir, { recursive: true, force: true });
-});
-
-test('runGit refuses git init even when a binary is available', () => {
- delete process.env.EVOLVER_GIT_BINARY;
- paths._resetGitBinaryCache();
- const r = paths.runGit(['init', '/tmp/evolver-should-not-init']);
- assert.equal(r.status, 1);
- assert.match(r.stderr, /never run git init/);
-});
-
-test('runGit refuses the Cursor installer argv (flags then init)', () => {
- delete process.env.EVOLVER_GIT_BINARY;
- paths._resetGitBinaryCache();
- const r = paths.runGit([
- '-c',
- 'credential.interactive=false',
- '-c',
- 'core.fsmonitor=false',
- 'init',
- ]);
- assert.equal(r.status, 1);
- assert.match(r.stderr, /never run git init/);
-});
-
-test('runGit refuses clone', () => {
- const r = paths.runGit(['clone', 'https://example.invalid/repo.git']);
- assert.equal(r.status, 1);
- assert.match(r.stderr, /never run git clone/);
-});
-
-test('empty EVOLVER_GIT_BINARY makes git unavailable without spawning', () => {
- process.env.EVOLVER_GIT_BINARY = '';
- paths._resetGitBinaryCache();
- assert.equal(paths.resolveGitBinary(), null);
- const r = paths.runGit(['rev-parse', '--is-inside-work-tree']);
- assert.equal(r.status, 1);
- assert.match(r.stderr, /not available/);
-});
-
-test('runGit rev-parse works in this repo when git is available', () => {
- delete process.env.EVOLVER_GIT_BINARY;
- paths._resetGitBinaryCache();
- const bin = paths.resolveGitBinary();
- assert.ok(bin);
- const r = paths.runGit(['rev-parse', '--is-inside-work-tree'], __dirname);
- assert.equal(r.status, 0);
- assert.equal(r.stdout.trim(), 'true');
-});
diff --git a/test/prime.test.mjs b/test/prime.test.mjs
new file mode 100644
index 0000000..880cc7e
--- /dev/null
+++ b/test/prime.test.mjs
@@ -0,0 +1,30 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { hubGene, promptTextOf } from '../lib/prime.mjs';
+
+const steps = (count) => Array.from({ length: count }, (unused, index) => `Step number ${index + 1} of the method`);
+const proxyWith = (assets) => async (method, path, body) => ({ ok: true, data: { assets, echo: body } });
+
+test('one strategy is injected, the best-scoring one with enough steps, ending with "Apply them where they fit."', async () => {
+ const outcomes = [];
+ const match = await hubGene(proxyWith([
+ { asset_id: 'thin', similarity: 0.9, strategy: steps(3) },
+ { asset_id: 'low', similarity: 0.1, strategy: steps(5) },
+ { asset_id: 'good', similarity: 0.6, short_title: 'Retry with backoff', strategy: steps(4) },
+ ]), 'add a retry to the uploader', { onOutcome: (outcome) => outcomes.push(outcome) });
+ assert.deepEqual(match.ids, ['good']);
+ assert.equal(match.text.split('\n')[0], '[Evolution Memory] Retry with backoff (EvoMap network):');
+ assert.equal(match.text.split('\n').at(-1), 'Apply them where they fit.');
+ assert.deepEqual(outcomes[0].dropped, { low_similarity: 1, too_few_steps: 1 });
+});
+
+test('a short prompt, an already injected asset or a Proxy error inject nothing', async () => {
+ assert.deepEqual(await hubGene(proxyWith([{ asset_id: 'a', strategy: steps(4) }]), 'fix it'), { ids: [], text: '' });
+ assert.deepEqual(await hubGene(proxyWith([{ asset_id: 'a', strategy: steps(4) }]), 'add a retry to the uploader', { listedIds: new Set(['a']) }), { ids: [], text: '' });
+ assert.deepEqual(await hubGene(async () => ({ ok: false, error: 'down' }), 'add a retry to the uploader'), { ids: [], text: '' });
+});
+
+test('the prompt loses keys and tokens before recall sends it', () => {
+ assert.equal(promptTextOf('fix it, token=ghp_abcdefghijklmnopqrstuvwxyz0123 now'), 'fix it, [redacted] now');
+});
diff --git a/test/process-owner.test.mjs b/test/process-owner.test.mjs
new file mode 100644
index 0000000..e81adf0
--- /dev/null
+++ b/test/process-owner.test.mjs
@@ -0,0 +1,29 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { hostProcess, ownerIsGone, processTableOf } from '../lib/process-owner.mjs';
+import { deadPid } from './helpers/sandbox.mjs';
+
+const TABLE = processTableOf([
+ ' 1 0 /sbin/launchd',
+ ' 500 1 /Applications/Cursor.app/Contents/MacOS/Cursor',
+ ' 600 500 /Applications/Cursor.app/Contents/Frameworks/Cursor Helper (Plugin).app/Contents/MacOS/Cursor Helper (Plugin)',
+ ' 700 600 /bin/zsh',
+ ' 800 700 /opt/homebrew/bin/node',
+ ' 900 1 /Users/me/.local/share/cursor-agent/versions/2026.10.20/node',
+ ' 910 900 /bin/bash',
+].join('\n'));
+
+test('the owner is the nearest Cursor ancestor, past the shell the extension host runs the hook in', () => {
+ assert.deepEqual(hostProcess(700, TABLE), { pid: 600 });
+ assert.deepEqual(hostProcess(600, TABLE), { pid: 600 }, 'a hook the extension host starts without a shell');
+ assert.deepEqual(hostProcess(910, TABLE), { pid: 900 }, 'a hook the CLI starts');
+});
+
+test('without a Cursor ancestor there is no owner, and no owner is never gone', () => {
+ assert.equal(hostProcess(1, TABLE), null);
+ assert.equal(hostProcess(999, TABLE), null);
+ assert.equal(ownerIsGone(null), false);
+ assert.equal(ownerIsGone(process.pid), false);
+ assert.equal(ownerIsGone(deadPid()), true);
+});
diff --git a/test/proxy.test.mjs b/test/proxy.test.mjs
new file mode 100644
index 0000000..e74cdb6
--- /dev/null
+++ b/test/proxy.test.mjs
@@ -0,0 +1,76 @@
+import assert from 'node:assert/strict';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import http from 'node:http';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import { afterEach, test } from 'node:test';
+
+import { createProxyClient } from '../lib/proxy.mjs';
+import { freshHome } from './helpers/sandbox.mjs';
+
+let restoreHome = null;
+let server = null;
+
+afterEach(async () => {
+ server?.closeAllConnections();
+ await new Promise((resolve) => (server ? server.close(resolve) : resolve()));
+ restoreHome?.();
+ server = null;
+ restoreHome = null;
+});
+
+async function listen(handler) {
+ server = http.createServer(handler);
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ return server.address().port;
+}
+
+function writeSettings(proxy) {
+ mkdirSync(join(homedir(), '.evolver'), { recursive: true });
+ writeFileSync(join(homedir(), '.evolver', 'settings.json'), JSON.stringify({ proxy }));
+}
+
+test('a Proxy that sends its headers and then stalls the body times out as a failed request, not a thrown error', async () => {
+ restoreHome = freshHome();
+ const port = await listen((req, res) => {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.write('{"assets": [');
+ });
+ writeSettings({ url: `http://127.0.0.1:${port}`, token: 't' });
+
+ const result = await createProxyClient({ timeoutMs: 200 })('POST', '/asset/fetch', { text: 'x' });
+
+ assert.equal(result.ok, false);
+ assert.match(result.error, /timed out/);
+});
+
+test('a caller that cancels while the body is still arriving gets a cancelled result', async () => {
+ restoreHome = freshHome();
+ const port = await listen((req, res) => {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.write('{');
+ });
+ writeSettings({ url: `http://127.0.0.1:${port}`, token: 't' });
+ const controller = new AbortController();
+ setTimeout(() => controller.abort(), 100);
+
+ const result = await createProxyClient({ timeoutMs: 5_000 })('GET', '/proxy/status', undefined, controller.signal);
+
+ assert.deepEqual(result, { ok: false, error: 'Proxy request cancelled.' });
+});
+
+test('the settings token goes only to the settings URL: an unusable URL falls back to the default port without it', async () => {
+ restoreHome = freshHome();
+ const seen = [];
+ const port = await listen((req, res) => {
+ seen.push(req.headers.authorization ?? null);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end('{"status":"running"}');
+ });
+ writeSettings({ url: 'http://evil.example:19820', token: 'secret-token' });
+
+ const result = await createProxyClient({ port: String(port) })('GET', '/proxy/status');
+
+ assert.equal(result.ok, true);
+ assert.deepEqual(seen, [null]);
+});
diff --git a/test/redact.test.mjs b/test/redact.test.mjs
new file mode 100644
index 0000000..149dea9
--- /dev/null
+++ b/test/redact.test.mjs
@@ -0,0 +1,30 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { withoutSecrets } from '../lib/redact.mjs';
+
+test('environment-style names carrying a secret are redacted with their value', () => {
+ for (const text of ['OPENAI_API_KEY=abcdef123456', 'MY_SECRET: hunter22hunter', 'export DB_PASSWORD="s3cr3t-value"', 'github-token=abcdefghij']) {
+ const cleaned = withoutSecrets(text);
+ assert.match(cleaned, /\[redacted\]/, text);
+ assert.doesNotMatch(cleaned, /abcdef123456|hunter22hunter|s3cr3t-value|abcdefghij/, text);
+ }
+});
+
+test('ordinary prose and short values are left alone', () => {
+ for (const text of ['How do I rotate a token safely?', 'tokenizer: bpe', 'secret=abc']) assert.equal(withoutSecrets(text), text);
+});
+
+test('a quoted secret is redacted whole, spaces included', () => {
+ const cleaned = withoutSecrets('export DB_PASSWORD="correct horse battery staple"');
+ assert.doesNotMatch(cleaned, /horse|battery|staple/);
+ assert.match(cleaned, /\[redacted\]/);
+});
+
+test('code identifiers that merely end in a secret word are left alone', () => {
+ for (const text of ['const cancellationToken = controller.signal;', 'resetPassword = buildResetFlow(user)']) assert.equal(withoutSecrets(text), text);
+});
+
+test('a secret behind an unclosed quote is still redacted', () => {
+ for (const text of ['API_KEY="abcdef123456', "TOKEN='abcdef123456"]) assert.doesNotMatch(withoutSecrets(text), /abcdef123456/, text);
+});
diff --git a/test/report-outbox.test.mjs b/test/report-outbox.test.mjs
new file mode 100644
index 0000000..77eb56c
--- /dev/null
+++ b/test/report-outbox.test.mjs
@@ -0,0 +1,44 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { OUTBOX_TTL_MS, isSettled, withAttempt, withQueued } from '../lib/report-outbox.mjs';
+
+const report = (taskId, assetId = 'sha256:a') => ({ asset_id: assetId, task_id: taskId, outcome: 'success', evidence: { tier: 'L3' } });
+
+test('the same report is queued once, keyed by task id and asset', () => {
+ const once = withQueued({}, [{ report: report('t:1:correction'), sessionId: 's' }], 0);
+ const twice = withQueued(withAttempt(once, Object.keys(once)[0], { verdict: 'sent' }, 1), [{ report: report('t:1:correction'), sessionId: 's' }], 2);
+ assert.equal(Object.keys(twice).length, 1);
+ assert.equal(Object.values(twice)[0].sentAt, 1);
+ assert.equal(Object.keys(withQueued(twice, [{ report: report('t:1:correction', 'sha256:b'), sessionId: 's' }], 3)).length, 2);
+});
+
+test('reports older than eight days are dropped', () => {
+ const queued = withQueued({}, [{ report: report('t:1'), sessionId: 's' }], 0);
+ assert.deepEqual(withQueued(queued, [], OUTBOX_TTL_MS + 1), {});
+});
+
+test('each delivery attempt is counted', () => {
+ const queued = withQueued({}, [{ report: report('t:1'), sessionId: 's' }], 0);
+ const key = Object.keys(queued)[0];
+ assert.equal(withAttempt(withAttempt(queued, key, { verdict: 'retry' }, 1), key, { verdict: 'retry' }, 2)[key].attempts, 2);
+ assert.equal(withAttempt(queued, 'missing', { verdict: 'sent' }, 1), queued);
+});
+
+test('a rejected report is settled with its reason and never marked sent', () => {
+ const queued = withQueued({}, [{ report: report('t:1'), sessionId: 's' }], 0);
+ const key = Object.keys(queued)[0];
+ const rejected = withAttempt(queued, key, { verdict: 'rejected', reason: 'hub 400', status: 400 }, 5)[key];
+ assert.equal(rejected.sentAt, undefined);
+ assert.equal(rejected.rejectedAt, 5);
+ assert.deepEqual(rejected.rejection, { reason: 'hub 400', status: 400 });
+ assert.equal(isSettled(rejected), true);
+ assert.equal(isSettled(withAttempt(queued, key, { verdict: 'retry' }, 5)[key]), false);
+});
+
+test('a delivered report keeps the receipt the Hub returned', () => {
+ const queued = withQueued({}, [{ report: report('t:1'), sessionId: 's' }], 0);
+ const key = Object.keys(queued)[0];
+ const receipt = { recorded: true, eligible: true, counted: true, weight: 0.6 };
+ assert.deepEqual(withAttempt(queued, key, { verdict: 'sent', receipt }, 3)[key].receipt, receipt);
+});
diff --git a/test/retention.test.mjs b/test/retention.test.mjs
new file mode 100644
index 0000000..4922c51
--- /dev/null
+++ b/test/retention.test.mjs
@@ -0,0 +1,25 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { ABSENT, retentionOf } from '../lib/retention.mjs';
+
+test('files still as the turn left them are kept', () => {
+ assert.deepEqual(retentionOf([{ before: 'a', after: 'b', now: 'b' }, { before: ABSENT, after: 'c', now: 'c' }]), { class: 'kept', files: 2 });
+});
+
+test('files back to how they stood before the turn are reverted', () => {
+ assert.deepEqual(retentionOf([{ before: 'a', after: 'b', now: 'a' }, { before: ABSENT, after: 'c', now: ABSENT }]), { class: 'reverted', files: 2 });
+});
+
+test('files changed again since the turn are rewritten', () => {
+ assert.equal(retentionOf([{ before: 'a', after: 'b', now: 'z' }]).class, 'rewritten');
+});
+
+test('a mix of kept and reverted files counts as rewritten', () => {
+ assert.equal(retentionOf([{ before: 'a', after: 'b', now: 'b' }, { before: 'c', after: 'd', now: 'c' }]).class, 'rewritten');
+});
+
+test('edits that netted out to nothing are not judged', () => {
+ assert.deepEqual(retentionOf([{ before: 'a', after: 'a', now: 'z' }]), { class: 'no_change', files: 0 });
+ assert.deepEqual(retentionOf([]), { class: 'no_change', files: 0 });
+});
diff --git a/test/session-distill.test.mjs b/test/session-distill.test.mjs
new file mode 100644
index 0000000..905b1b6
--- /dev/null
+++ b/test/session-distill.test.mjs
@@ -0,0 +1,181 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { distillDue, distillSession } from '../lib/distiller.mjs';
+import {
+ argumentsOf, candidateOf, claimLeftoverCandidates, claimSessionCandidate, closeCandidate, distillCandidate, distillPrompt,
+ dropCandidate, dueCandidates, isCurrentCandidate, rememberCandidate, settleCandidate, skipReason,
+} from '../lib/session-distill.mjs';
+import { freshHome } from './helpers/sandbox.mjs';
+
+const MODEL = { model: 'gpt-test' };
+const GENE = { title: 'Retry uploads', summary: 'Retry a flaky upload with backoff.', strategy: '1. Measure. 2. Back off.', signals: ['retry'] };
+
+function record(overrides = {}) {
+ return { checks: [], commands: ['npm test'], paths: ['src/up.js'], edits: [], answer: 'Done: retries added.', ...overrides };
+}
+
+function fakeModel(answer = JSON.stringify(GENE)) {
+ const calls = [];
+ const runModel = async (model, prompt, signal) => {
+ calls.push({ model, prompt });
+ await runModel.hold;
+ if (signal.aborted) throw Object.assign(new Error('cancelled'), { code: 'ABORT_ERR' });
+ return { answer: runModel.answer ?? answer, usage: { inputTokens: 900, outputTokens: 120 } };
+ };
+ runModel.calls = calls;
+ return runModel;
+}
+
+function recordingProxy() {
+ const sent = [];
+ const proxyFetch = async (method, path, body) => {
+ sent.push({ path, body });
+ return { ok: true, data: { id: 'gene_x' } };
+ };
+ return { sent, proxyFetch };
+}
+
+test('only a verified, completed turn that used tools and reused no strategy becomes a candidate', () => {
+ assert.equal(skipReason('completed', record()), null);
+ assert.equal(skipReason('aborted', record()), 'not_completed');
+ assert.equal(skipReason('error', record()), 'not_completed');
+ assert.equal(skipReason('completed', record({ answer: '' })), 'no_answer');
+ assert.equal(skipReason('completed', record({ checks: [{ command: 'npm test', failed: true }] })), 'failed_check');
+ assert.equal(skipReason('completed', record({ commands: [] })), 'no_tool_use');
+ assert.equal(skipReason('completed', record(), { reusedAsset: true }), 'asset_reused');
+});
+
+test('the prompt is a digest of the turn, never its whole history, and asks for no answer values', () => {
+ const candidate = candidateOf({ prompt: 'add a retry to the uploader', record: record({ checks: [{ command: 'npm test', failed: false }] }), model: MODEL, turn: 1 });
+ const prompt = distillPrompt(candidate, 'the backoff');
+ assert.match(prompt, /## Task\nadd a retry to the uploader/);
+ assert.match(prompt, /- npm test/);
+ assert.match(prompt, /- passed: npm test/);
+ assert.match(prompt, /Focus on: the backoff/);
+ assert.match(prompt, /Do not include the answer itself/);
+});
+
+test('a list the model wrote as one numbered paragraph becomes steps', () => {
+ assert.deepEqual(argumentsOf(JSON.stringify(GENE)).strategy, ['Measure.', 'Back off.']);
+ assert.equal(argumentsOf('no json here'), null);
+ assert.equal(argumentsOf('{"title":"no summary"}'), null);
+});
+
+test('a candidate is distilled on the session model and sent to the Proxy with its own cost', async () => {
+ const { sent, proxyFetch } = recordingProxy();
+ const candidate = candidateOf({ prompt: 'add a retry', record: record(), model: MODEL, turn: 1 });
+ const runModel = fakeModel();
+ const outcome = await distillCandidate({ proxyFetch, runModel, candidate, timeoutMs: 1_000 });
+
+ assert.equal(outcome.status, 'sent');
+ assert.equal(runModel.calls[0].model, 'gpt-test');
+ assert.equal(sent[0].path, '/conversation/distill');
+ assert.equal(sent[0].body.platform, 'cursor');
+ assert.equal(sent[0].body.trigger, 'session_end');
+ assert.deepEqual(sent[0].body.distill_usage, { inputTokens: 900, outputTokens: 120 });
+ assert.equal((await distillCandidate({ proxyFetch, runModel, candidate: { ...candidate, model: null }, timeoutMs: 1_000 })).status, 'no_model');
+ const missing = async () => { throw Object.assign(new Error('spawn cursor-agent ENOENT'), { code: 'ENOENT' }); };
+ assert.equal((await distillCandidate({ proxyFetch, runModel: missing, candidate, timeoutMs: 1_000 })).status, 'no_model');
+ const broken = async () => { throw new Error('cursor-agent exited 1: Authentication required'); };
+ assert.deepEqual(await distillCandidate({ proxyFetch, runModel: broken, candidate, timeoutMs: 1_000 }), { status: 'failed', error: 'cursor-agent exited 1: Authentication required' });
+ assert.equal((await distillCandidate({ proxyFetch, runModel: fakeModel('not json'), candidate, timeoutMs: 1_000 })).status, 'unparsed');
+});
+
+test('a candidate dropped or cancelled while the model answers is never sent, and a cancelled one without an answer is dropped', async (t) => {
+ t.after(freshHome());
+ const { sent, proxyFetch } = recordingProxy();
+ const candidate = candidateOf({ prompt: 'add a retry', record: record(), model: MODEL, turn: 1 });
+ rememberCandidate('s', candidate);
+ assert.equal(isCurrentCandidate('s', candidate), true);
+ rememberCandidate('s', { ...candidate, at: candidate.at + 1 });
+ assert.equal(isCurrentCandidate('s', candidate), false);
+
+ const replaced = await distillCandidate({ proxyFetch, runModel: fakeModel(), candidate, timeoutMs: 1_000, isCurrent: () => isCurrentCandidate('s', candidate) });
+ const controller = new AbortController();
+ const runModel = fakeModel();
+ runModel.hold = new Promise((resolve) => setTimeout(resolve, 20));
+ const cancelled = distillCandidate({ proxyFetch, runModel, candidate, timeoutMs: 1_000, cancel: controller.signal });
+ controller.abort();
+
+ assert.equal(replaced.status, 'dropped');
+ assert.equal((await cancelled).status, 'dropped');
+ assert.deepEqual(sent, []);
+});
+
+test('a correction that drops the candidate stops the distillation running in another process', async (t) => {
+ t.after(freshHome());
+ const { sent, proxyFetch } = recordingProxy();
+ const candidate = candidateOf({ prompt: 'add a retry', record: record(), model: MODEL, turn: 1 });
+ rememberCandidate('s', candidate);
+ const runModel = fakeModel();
+ let release;
+ runModel.hold = new Promise((resolve) => { release = resolve; });
+ const running = distillDue({ sessionId: 's', candidate }, { proxyFetch, runModel, pollMs: 10 });
+ dropCandidate('s');
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ release();
+ assert.equal((await running).status, 'dropped');
+ assert.deepEqual(sent, []);
+});
+
+test("a session's candidate is due to it; another session's only once that session is over", (t) => {
+ t.after(freshHome());
+ rememberCandidate('live', { pid: 111, at: 1 });
+ rememberCandidate('killed', { pid: 222, at: 2 });
+ rememberCandidate('ended', { pid: 111, at: 3 });
+ rememberCandidate('unknown-owner', { pid: null, at: 4 });
+ closeCandidate('ended');
+ const isGone = (pid) => pid === 222;
+ assert.deepEqual(dueCandidates({ sessionId: 'live' }).map((due) => due.sessionId), ['live']);
+ assert.deepEqual(claimLeftoverCandidates({ isGone, pid: 7 }).map((due) => due.sessionId), ['killed', 'ended']);
+});
+
+test('a leftover candidate is claimed by one process only, and again once the claim dies', (t) => {
+ t.after(freshHome());
+ const isGone = (pid) => pid !== 111;
+ rememberCandidate('killed', { pid: 222, at: 1 });
+
+ assert.deepEqual(claimLeftoverCandidates({ isGone, pid: 111 }).map((due) => due.sessionId), ['killed']);
+ assert.deepEqual(claimLeftoverCandidates({ isGone, pid: 333 }), [], 'a live claim is left alone');
+ assert.deepEqual(claimSessionCandidate('killed', { isGone, pid: 333 }), [], 'also for a distillation asked for now');
+ settleCandidate('killed', { at: 1 }, { sent: false });
+ assert.deepEqual(claimLeftoverCandidates({ isGone, pid: 333 }).map((due) => due.candidate.attempts), [1], 'a failed try is claimable again');
+});
+
+test('a candidate leaves the disk only once distilled, or after three failed tries', (t) => {
+ t.after(freshHome());
+ const candidate = { pid: process.pid, at: 5, attempts: 0 };
+ rememberCandidate('s', candidate);
+ settleCandidate('s', candidate, { sent: false });
+ settleCandidate('s', candidate, { sent: false });
+ assert.equal(dueCandidates({ sessionId: 's' })[0].candidate.attempts, 2, 'kept for a retry');
+ settleCandidate('s', candidate, { sent: false });
+ assert.deepEqual(dueCandidates({ sessionId: 's' }), [], 'given up');
+
+ rememberCandidate('s', candidate);
+ rememberCandidate('s', { ...candidate, at: 9 });
+ settleCandidate('s', candidate, { sent: true });
+ assert.equal(dueCandidates({ sessionId: 's' })[0].candidate.at, 9, 'a newer candidate written meanwhile stays');
+});
+
+test('a distillation asked for now claims the session candidate, and reports nothing when there is none', async (t) => {
+ t.after(freshHome());
+ const { sent, proxyFetch } = recordingProxy();
+ assert.deepEqual(await distillSession('none', { proxyFetch, runModel: fakeModel() }), { status: 'nothing' });
+ rememberCandidate('s', candidateOf({ prompt: 'add a retry', record: record(), model: MODEL, turn: 1 }));
+ const outcome = await distillSession('s', { proxyFetch, runModel: fakeModel(), focus: 'the backoff' });
+ assert.equal(outcome.status, 'sent');
+ assert.equal(sent.length, 1);
+ assert.deepEqual(dueCandidates({ sessionId: 's' }), []);
+});
+
+test('keys and tokens never reach the candidate, nor the prompt a manual focus writes', () => {
+ const secret = 'sk-evomap-0123456789abcdef0123456789abcdef';
+ const candidate = candidateOf({
+ prompt: `use ${secret}`, model: MODEL, turn: 1,
+ record: record({ commands: [`curl -H "Authorization: Bearer ${secret}" x`], answer: `done with ${secret}`, paths: [`keys/${secret}.pem`] }),
+ });
+ assert.doesNotMatch(JSON.stringify(candidate), new RegExp(secret));
+ assert.doesNotMatch(distillPrompt(candidate, `Authorization: Bearer ${secret}`), new RegExp(secret));
+});
diff --git a/test/state-file.test.mjs b/test/state-file.test.mjs
new file mode 100644
index 0000000..2bb48af
--- /dev/null
+++ b/test/state-file.test.mjs
@@ -0,0 +1,69 @@
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import fs from 'node:fs';
+import { test } from 'node:test';
+
+import { readState, statePath, updateState, withLock } from '../lib/state-file.mjs';
+import { deadPid, freshHome } from './helpers/sandbox.mjs';
+
+const MODULE = new URL('../lib/state-file.mjs', import.meta.url).href;
+const WRITERS = 4;
+const PER_WRITER = 15;
+
+const CHILD = `
+ const { statePath, updateState } = await import(process.argv[1]);
+ for (const key of process.argv.slice(2)) {
+ updateState(statePath('shared.json'), {}, (state) => ({ ...state, [key]: true }));
+ }
+`;
+
+function writer(keys) {
+ return new Promise((resolve) => {
+ const child = spawn(process.execPath, ['--input-type=module', '-e', CHILD, MODULE, ...keys], { stdio: 'ignore' });
+ child.on('exit', resolve);
+ });
+}
+
+test('an update is read, changed and written under a lock, so parallel writers keep every entry', async (t) => {
+ t.after(freshHome());
+ const keysOf = (writerIndex) => Array.from({ length: PER_WRITER }, (unused, index) => `w${writerIndex}-${index}`);
+ await Promise.all(Array.from({ length: WRITERS }, (unused, index) => writer(keysOf(index))));
+ assert.equal(Object.keys(readState(statePath('shared.json'), {})).length, WRITERS * PER_WRITER);
+});
+
+test('an update that changes nothing leaves the file alone and releases its lock', (t) => {
+ t.after(freshHome());
+ updateState(statePath('kept.json'), {}, () => ({ a: 1 }));
+ updateState(statePath('kept.json'), {}, () => undefined);
+ assert.deepEqual(readState(statePath('kept.json'), {}), { a: 1 });
+ assert.deepEqual(updateState(statePath('kept.json'), {}, (state) => ({ ...state, b: 2 })), { a: 1, b: 2 });
+});
+
+test("a dead owner's lock is taken over, and a lock someone else holds by the time of release is left to them", (t) => {
+ t.after(freshHome());
+ const target = statePath('stale.json');
+ const lockPath = `${target}.lock`;
+ fs.mkdirSync(statePath(''), { recursive: true });
+ fs.writeFileSync(lockPath, `${deadPid()}:dead`);
+
+ updateState(target, {}, () => ({ a: 1 }));
+ assert.equal(fs.existsSync(lockPath), false, "the dead owner's lock is gone after the update");
+
+ updateState(target, {}, (state) => {
+ fs.writeFileSync(lockPath, `${process.pid}:someone-else`);
+ return { ...state, b: 2 };
+ });
+ assert.equal(fs.readFileSync(lockPath, 'utf8'), `${process.pid}:someone-else`);
+ assert.deepEqual(readState(target, {}), { a: 1, b: 2 });
+});
+
+test('a long job runs in one process at a time, and a dead holder does not block the next', async (t) => {
+ t.after(freshHome());
+ let release;
+ const first = withLock('job', () => new Promise((resolve) => { release = resolve; }));
+ assert.equal(await withLock('job', async () => {}), false);
+ release();
+ assert.equal(await first, true);
+ fs.writeFileSync(statePath('job.lock'), `${deadPid()}:dead`);
+ assert.equal(await withLock('job', async () => {}), true);
+});
diff --git a/test/turn-record.test.mjs b/test/turn-record.test.mjs
new file mode 100644
index 0000000..24c7991
--- /dev/null
+++ b/test/turn-record.test.mjs
@@ -0,0 +1,41 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { editNoteOf, failureNoteOf, noteOf, turnRecordOf } from '../lib/turn-record.mjs';
+
+const shell = (command, output, exitCode = 0) => noteOf({ tool_name: 'Shell', tool_input: { command, cwd: '/p' }, tool_output: JSON.stringify({ output, exitCode }) });
+
+test('a turn record keeps commands, checks with their verdict, touched paths, applied edits and the answer', () => {
+ const record = turnRecordOf([
+ { kind: 'before', path: '/p/src/lru.js', hash: 'absent' },
+ shell('ls src', 'lru.js'),
+ shell('npm test', 'AssertionError: expected 1 to equal 2', 1),
+ noteOf({ tool_name: 'Read', tool_input: { file_path: '/p/src/old.js' }, tool_output: '{}' }),
+ noteOf({ tool_name: 'Write', tool_input: { file_path: '/p/src/lru.js', content: 'whole file' }, tool_output: '{"success":true}' }),
+ editNoteOf({ file_path: '/p/src/lru.js', edits: [{ old_string: 'a', new_string: 'export const cache = new Map();' }, { old_string: 'b', new_string: '' }] }),
+ shell('npm test', 'ℹ pass 3\nℹ fail 0'),
+ { kind: 'answer', text: 'first' },
+ { kind: 'answer', text: 'Done.' },
+ { kind: 'before', path: '/p/src/lru.js', hash: 'later' },
+ ]);
+ assert.deepEqual(record.commands, ['ls src', 'npm test', 'npm test']);
+ assert.deepEqual(record.checks, [{ command: 'npm test', failed: true }, { command: 'npm test', failed: false }]);
+ assert.deepEqual(record.edits, ['export const cache = new Map();'], 'only what the edit wrote in, not the whole file');
+ assert.deepEqual(record.paths, ['/p/src/old.js', '/p/src/lru.js', '/p/src/lru.js']);
+ assert.deepEqual([...record.editedBefore], [['/p/src/lru.js', 'absent']]);
+ assert.equal(record.answer, 'Done.');
+});
+
+test('a failed Shell call fails its check unless the person interrupted it, and a denied one never ran', () => {
+ const failure = (fields) => failureNoteOf({ tool_name: 'Shell', tool_input: { command: 'npm test' }, error_message: 'Command failed with exit code 1', failure_type: 'error', ...fields });
+ assert.deepEqual(failure({}).check, { command: 'npm test', failed: true });
+ assert.deepEqual(failure({ failure_type: 'timeout' }).check, { command: 'npm test', failed: true });
+ assert.equal(failure({ is_interrupt: true }).check, undefined);
+ assert.equal(failure({ is_interrupt: true }).command, 'npm test');
+ assert.equal(failure({ failure_type: 'permission_denied' }), null);
+});
+
+test('Shell output that is not JSON is read as plain output', () => {
+ const [check] = turnRecordOf([noteOf({ tool_name: 'Shell', tool_input: { command: 'npm test' }, tool_output: 'Tests: 1 failed, 0 errors' })]).checks;
+ assert.deepEqual(check, { command: 'npm test', failed: true });
+});
diff --git a/test/verification.test.mjs b/test/verification.test.mjs
new file mode 100644
index 0000000..5f4d449
--- /dev/null
+++ b/test/verification.test.mjs
@@ -0,0 +1,22 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { shellCheckFailed, verificationFailed } from '../lib/verification.mjs';
+
+test('a summary that counts real failures beside zero errors still fails', () => {
+ assert.equal(verificationFailed('1 failed, 0 errors'), true);
+ assert.equal(verificationFailed('tests 3 | failed: 2 | errors: 0'), true);
+});
+
+test('a passing summary that counts zero failures passes', () => {
+ for (const text of ['ℹ tests 78\nℹ pass 78\nℹ fail 0', 'Tests: 0 failed, 12 passed', 'failures: 0\nerrors: 0', '✔ Validation passed']) {
+ assert.equal(verificationFailed(text), false, text);
+ }
+});
+
+test('a non-zero exit code decides; a zero one still reads the output, since a piped failing suite exits zero', () => {
+ assert.equal(shellCheckFailed({ exitCode: 1, output: 'all good' }), true);
+ assert.equal(shellCheckFailed({ exitCode: 0, output: 'ℹ pass 3\nℹ fail 0' }), false);
+ assert.equal(shellCheckFailed({ exitCode: 0, output: '✖ evicts the oldest\nAssertionError [ERR_ASSERTION]: Expected values to be strictly equal:' }), true);
+ assert.equal(shellCheckFailed({ output: 'not ok 1 - adds\n# fail 1\nAssertionError: expected 2' }), true);
+});
diff --git a/test/windows-quoting.test.mjs b/test/windows-quoting.test.mjs
new file mode 100644
index 0000000..1932f63
--- /dev/null
+++ b/test/windows-quoting.test.mjs
@@ -0,0 +1,19 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { windowsQuoted } from '../lib/engine-runtime.mjs';
+
+test('plain arguments pass through unquoted', () => {
+ assert.equal(windowsQuoted('--no-audit'), '--no-audit');
+ assert.equal(windowsQuoted('C:\\Users\\joy\\engine'), 'C:\\Users\\joy\\engine');
+});
+
+test('a path with spaces is quoted and its trailing backslash cannot escape the closing quote', () => {
+ assert.equal(windowsQuoted('C:\\Users\\John Doe\\engine'), '"C:\\Users\\John Doe\\engine"');
+ assert.equal(windowsQuoted('C:\\Users\\John Doe\\'), '"C:\\Users\\John Doe\\\\"');
+});
+
+test('quotes are escaped together with the backslashes in front of them', () => {
+ assert.equal(windowsQuoted('say "hi"'), '"say \\"hi\\""');
+ assert.equal(windowsQuoted('a\\"b c'), '"a\\\\\\"b c"');
+});
From b783935d765bc7ca5f6b5dc38f633da4886fe37d Mon Sep 17 00:00:00 2001
From: forrestlinfeng
Date: Mon, 26 Oct 2026 14:20:59 +0800
Subject: [PATCH 2/7] feat: count an injected strategy as used, and distill
turns that reused one
Port of EvoMap/evolver-dsh-plugin#55. Every reuse report (turn end,
correction, retention, aborted) now says `applied: used`: the model follows
what is injected into its turn, and whether the strategy helped is left to
the outcome (last check, or how the turn ended), the next prompt and kept
edits. `tier` is L1 whenever a check ran, and the reason no longer names a
verdict.
A turn that reused an injected strategy is a distillation candidate like any
verified turn; the engine's Gene intake drops a repeat of a Gene it has.
applied.mjs and the edited-text capture existed only for the verdict and are
removed; a turn records the files it edited (afterFileEdit writes, Delete).
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 4 +-
README.md | 24 +++++-----
lib/applied.mjs | 67 ---------------------------
lib/reuse.mjs | 20 ++++----
lib/session-distill.mjs | 9 ++--
lib/turn-end.mjs | 15 ++----
lib/turn-record.mjs | 22 +++++----
test/applied.test.mjs | 86 -----------------------------------
test/hooks.test.mjs | 24 +++++++---
test/session-distill.test.mjs | 1 -
test/turn-record.test.mjs | 9 +++-
11 files changed, 69 insertions(+), 212 deletions(-)
delete mode 100644 lib/applied.mjs
delete mode 100644 test/applied.test.mjs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ef05286..d78f900 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,7 +18,9 @@ Aligned with the DeepSeek Harness plugin (dsh-evolver 0.3.0): Evolver stays out
`cursor::`.
- Aborted reports when a chat closes mid-turn, and at the next session start for a chat whose Cursor process is
gone; correction/praise follow-ups, retention reports, a report outbox with retries, and local root-event records.
-- Session-end distillation: a verified turn that reused no strategy becomes the chat's candidate
+- Every reuse report says `applied: used` (dsh-evolver #55): the model follows what is injected; the outcome,
+ the next prompt and kept edits judge whether it helped. `tier` is `L1` whenever a check ran.
+- Session-end distillation: a verified turn becomes the chat's candidate (one that reused a strategy too)
(`~/.evolver/state/cursor-distill-pending.json`), dropped by a correction, and is distilled once in a detached
worker by the Cursor agent CLI, headless (`cursor-agent -p --output-format json --mode ask`, empty workspace,
on the chat's model), then sent to `POST /conversation/distill` with `platform: 'cursor'`. Three tries;
diff --git a/README.md b/README.md
index 2fcdb56..4899292 100644
--- a/README.md
+++ b/README.md
@@ -83,8 +83,7 @@ Cursor shows a command's answer in the chat as a message the hook blocked the pr
## Session-end distillation
-A turn that ended `completed`, used tools, passed its last check and reused no injected strategy leaves a
-candidate in `~/.evolver/state/cursor-distill-pending.json`; a later verified turn replaces it, and a next prompt
+A turn that ended `completed`, used tools and passed its last check leaves a candidate in `~/.evolver/state/cursor-distill-pending.json`; a later verified turn replaces it, and a next prompt
that reads as a correction drops it (a distillation already running sees that and stops). When the chat closes,
a detached worker runs the Cursor agent CLI headless on the chat's model —
`cursor-agent -p --output-format json --mode ask --trust --workspace --model `,
@@ -92,7 +91,8 @@ read-only, without `--force` so nothing is applied — to write a Gene from a di
touched paths, checks, final answer — never the whole history), and sends it to the Proxy's
`POST /conversation/distill` with `platform: 'cursor'`, `trigger: 'session_end'`, `persist: true` and
`publish: false`. The CLI reports no token usage, so no `distill_usage` is sent. Keys and tokens are stripped from
-the candidate first, paths and a manual focus included.
+the candidate first, paths and a manual focus included. A turn that reused an injected strategy is a candidate
+too: the engine's Gene intake drops a Gene that repeats one it already has.
A candidate stays on disk until it is distilled; three failed tries (no `cursor-agent`, not signed in, an
unparsed answer, a Proxy error) give it up. One whose chat is over — closed, or left by a Cursor process that is
@@ -115,15 +115,13 @@ Every strategy injected into a turn is reported through the Proxy's `POST /asset
| Report | When | `task_id` | `evidence` |
|---|---|---|---|
-| Turn end | `stop`, or the next prompt after a turn whose `stop` never came | `cursor::` | `tier` `L1` when the turn ran a test, build, lint or type check, else `none`; `applied`; `check` `{kind, passed, fail_then_pass}` of the last check; `aborted`; `stage: immediate` |
-| Correction | the next prompt says the answer did not hold, or approves it | `…:correction` | `tier: L3`, `applied`, `stage: correction` |
-| Retention | the next chat in the same project finds the turn's edits kept or reverted | `…:retention` | `tier: L2`, `applied`, `stage: retention` |
-| Aborted | the chat closes, or its Cursor process goes away, before the turn ends (those of a process that is gone go out at the next session start) | `cursor::` | `tier: none`, `applied: partial`, `aborted: true`, `stage: immediate` |
-
-- **`applied`** compares the strategy's numbered steps with the turn's shell commands, touched paths, the text its
- edits wrote in (200,000 characters a turn) and the final answer: `used` when half the steps show up, `partial`
- when fewer do, `unused` when none do. A turn that left nothing to compare is sent as tier `none` with
- `applied: partial`, and its reason says `applied: unknown`.
+| Turn end | `stop`, or the next prompt after a turn whose `stop` never came | `cursor::` | `tier` `L1` when the turn ran a test, build, lint or type check, else `none`; `applied: used`; `check` `{kind, passed, fail_then_pass}` of the last check; `aborted`; `stage: immediate` |
+| Correction | the next prompt says the answer did not hold, or approves it | `…:correction` | `tier: L3`, `applied: used`, `stage: correction` |
+| Retention | the next chat in the same project finds the turn's edits kept or reverted | `…:retention` | `tier: L2`, `applied: used`, `stage: retention` |
+| Aborted | the chat closes, or its Cursor process goes away, before the turn ends (those of a process that is gone go out at the next session start) | `cursor::` | `tier: none`, `applied: used`, `aborted: true`, `stage: immediate` |
+
+- **`applied`** is always `used`: the model follows a strategy injected into its turn. Whether it helped is judged
+ by the outcome (the last check, or how the turn ended), the next prompt and whether the edits were kept.
- **Checks** are `Shell` commands that run tests, builds, linters or type checkers. A non-zero exit code
(`postToolUse`'s `exitCode`, or a `postToolUseFailure` for an error or a timeout) fails one; with exit code 0 the
output is read too, since a failing suite piped through `tail` exits zero. A command the person interrupted has no
@@ -133,7 +131,7 @@ Every strategy injected into a turn is reported through the Proxy's `POST /asset
- **Delivery.** Reports wait in `~/.evolver/state/cursor-reports-outbox.json` and are retried by the background
worker at every turn end, follow-up prompt and session start for 8 days. One the Hub refuses as invalid is
settled as rejected and never resent. Reports with evidence are also written once to the local root event log.
-- Reasons name the turn, the applied verdict and the check kind, never the prompt, a command or code, and the
+- Reasons name the turn and the check kind, never the prompt, a command or code, and the
prompt loses keys and tokens before recall sends it.
Every state file is prefixed `cursor-`, so another host's Evolver plugin never claims or reports this one's.
diff --git a/lib/applied.mjs b/lib/applied.mjs
deleted file mode 100644
index 2268a52..0000000
--- a/lib/applied.mjs
+++ /dev/null
@@ -1,67 +0,0 @@
-const STEP_PREFIX = /^\s*\d+\.\s+/;
-const BACKTICKED = /`([^`\n]{2,120})`/g;
-const PATH_LIKE = /(?:^|[\s("'])((?:\.{0,2}\/)?[\w@.-]+(?:\/[\w@.-]+)+|[\w-]+\.(?:js|mjs|cjs|ts|tsx|jsx|py|go|rs|java|kt|rb|php|json|ya?ml|toml|md|sh|sql|css|html|vue|svelte))(?=$|[\s)"',.:;])/g;
-const COMMAND_LIKE = /\b((?:npm|npx|pnpm|yarn|bun|node|git|go|cargo|pytest|python3?|pip|make|docker|kubectl|tsc|eslint|jest|vitest|cursor-agent|gh)\s+[\w:@./=-]+)/g;
-const WORD = /[\p{L}\p{N}_]{4,}/gu;
-const STOPWORDS = new Set([
- 'that', 'this', 'with', 'from', 'into', 'then', 'when', 'each', 'only', 'before', 'after', 'step', 'steps', 'make', 'sure',
- 'should', 'must', 'never', 'always', 'use', 'using', 'used', 'have', 'has', 'were', 'will', 'your', 'their', 'there', 'these',
- 'those', 'what', 'which', 'while', 'until', 'other', 'every', 'more', 'than', 'also', 'apply', 'where', 'fits', 'report',
- 'outcome', 'evolver_asset_reuse_result', 'safety', 'boundary', 'optional', 'otherwise', 'first', 'next', 'last', 'same',
-]);
-
-const USED_RATIO = 0.5;
-export const UNKNOWN = 'unknown';
-const TERM_COVERAGE = 0.5;
-const MIN_STEP_TERMS = 3;
-
-export function strategyStepsOf(injectedText) {
- return String(injectedText ?? '')
- .split('\n')
- .filter((line) => STEP_PREFIX.test(line))
- .map((line) => line.replace(STEP_PREFIX, '').trim());
-}
-
-function matchesOf(text, pattern) {
- return [...text.matchAll(pattern)].map((match) => match[1].trim().toLowerCase());
-}
-
-export function concreteFeaturesOf(step) {
- const features = [...matchesOf(step, BACKTICKED), ...matchesOf(step, PATH_LIKE), ...matchesOf(step, COMMAND_LIKE)];
- return [...new Set(features.map((feature) => feature.replace(/[.,:;]+$/, '')).filter((feature) => feature.length >= 3))];
-}
-
-function termsOf(text) {
- return [...new Set((String(text).toLowerCase().match(WORD) ?? []).filter((term) => !STOPWORDS.has(term)))];
-}
-
-function turnHaystack({ commands = [], paths = [], edits = [], answer = '' }) {
- return [...commands, ...paths, ...edits, answer].join('\n').toLowerCase();
-}
-
-function termsWereFollowed(step, haystack) {
- const terms = termsOf(step);
- if (terms.length < MIN_STEP_TERMS) return false;
- return terms.filter((term) => haystack.includes(term)).length / terms.length >= TERM_COVERAGE;
-}
-
-function stepWasFollowed(step, haystack) {
- return concreteFeaturesOf(step).some((feature) => haystack.includes(feature)) || termsWereFollowed(step, haystack);
-}
-
-function leftNothingToCompare({ commands = [], paths = [], edits = [] }) {
- return commands.length + paths.length + edits.length === 0;
-}
-
-function verdictOf(turn, steps, hits) {
- if (hits > 0) return hits / steps >= USED_RATIO ? 'used' : 'partial';
- return steps === 0 || leftNothingToCompare(turn) ? UNKNOWN : 'unused';
-}
-
-export function appliedVerdictOf({ strategyText, ...turn }) {
- const steps = strategyStepsOf(strategyText);
- const haystack = turnHaystack(turn);
- const hits = steps.filter((step) => stepWasFollowed(step, haystack)).length;
- const ratio = steps.length === 0 ? 0 : hits / steps.length;
- return { verdict: verdictOf(turn, steps.length, hits), hits, steps: steps.length, ratio: Math.round(ratio * 1000) / 1000 };
-}
diff --git a/lib/reuse.mjs b/lib/reuse.mjs
index 3cc87aa..a4de5dd 100644
--- a/lib/reuse.mjs
+++ b/lib/reuse.mjs
@@ -1,4 +1,3 @@
-import { UNKNOWN } from './applied.mjs';
import { checkEvidenceOf } from './checks.mjs';
import { REJECTED, RETRY, SENT, isSettled, withAttempt } from './report-outbox.mjs';
import { outcomeOfReason } from './turn-outcomes.mjs';
@@ -11,16 +10,15 @@ function turnTaskId(sessionId, turn) {
return `cursor:${sessionId || 'unknown'}:${turn ?? 'unknown'}`;
}
-// The Hub has no `unknown`, and `unused` carries a soft negative there; a
-// partial reuse at tier none weighs nothing either way.
-const UNKNOWN_APPLIED_AS = 'partial';
+// The model follows a strategy injected into its turn, so an injection is a use; whether it helped is
+// what the outcome, the check, the next prompt and the edits' retention tell.
+const APPLIED = 'used';
-function immediateEvidenceOf({ checks, applied, reasonKind }) {
+function immediateEvidenceOf({ checks, reasonKind }) {
const check = checkEvidenceOf(checks);
- const isKnown = applied !== UNKNOWN;
return {
- tier: check && isKnown ? 'L1' : 'none',
- applied: isKnown ? applied : UNKNOWN_APPLIED_AS,
+ tier: check ? 'L1' : 'none',
+ applied: APPLIED,
...(check && { check }),
aborted: reasonKind === 'aborted',
stage: 'immediate',
@@ -42,13 +40,13 @@ function reportOf({ assetId, taskId, outcome, reason, evidence }) {
return { asset_id: assetId, outcome, task_id: taskId, reason, evidence };
}
-export function immediateReportOf({ asset, sessionId, turn, reasonKind, checks, applied }) {
- const evidence = immediateEvidenceOf({ checks, applied, reasonKind });
+export function immediateReportOf({ asset, sessionId, turn, reasonKind, checks }) {
+ const evidence = immediateEvidenceOf({ checks, reasonKind });
return reportOf({
assetId: asset.assetId,
taskId: turnTaskId(sessionId, turn),
outcome: immediateOutcomeOf(evidence, reasonKind),
- reason: `Injected by Evolver into Cursor turn ${turn}; applied: ${applied}; ${checkPhrase(evidence.check)}.`,
+ reason: `Injected by Evolver into Cursor turn ${turn}; ${checkPhrase(evidence.check)}.`,
evidence,
});
}
diff --git a/lib/session-distill.mjs b/lib/session-distill.mjs
index f95df64..7876b2d 100644
--- a/lib/session-distill.mjs
+++ b/lib/session-distill.mjs
@@ -20,16 +20,15 @@ const MAX_ATTEMPTS = 3;
export const pendingPath = () => statePath('cursor-distill-pending.json');
/**
- * Why a finished turn leaves no candidate, or null when it does. A turn that reused an injected strategy
- * adds evidence to that asset through its reuse report rather than a near-copy of it; a chat reply without a
- * single command has no method to keep.
+ * Why a finished turn leaves no candidate, or null when it does. A chat reply without a single command has no
+ * method to keep. A turn that reused an injected strategy is distilled too: the engine's intake drops a Gene
+ * that repeats one it already has.
*/
-export function skipReason(reasonKind, record, { reusedAsset = false } = {}) {
+export function skipReason(reasonKind, record) {
if (reasonKind !== 'completed') return 'not_completed';
if (!record.answer) return 'no_answer';
if (record.checks.at(-1)?.failed) return 'failed_check';
if (record.commands.length === 0 && record.edits.length === 0) return 'no_tool_use';
- if (reusedAsset) return 'asset_reused';
return null;
}
diff --git a/lib/turn-end.mjs b/lib/turn-end.mjs
index c4d23ec..333acf9 100644
--- a/lib/turn-end.mjs
+++ b/lib/turn-end.mjs
@@ -1,4 +1,3 @@
-import { appliedVerdictOf } from './applied.mjs';
import { takeUnreported } from './injected-assets.mjs';
import { queueReports } from './report-queue.mjs';
import { rememberForRetention } from './retention-pending.mjs';
@@ -8,8 +7,6 @@ import { closeOpenTurn, settleTurn } from './session-state.mjs';
import { takeTurnRecord } from './turn-record.mjs';
import { outcomeOfReason } from './turn-outcomes.mjs';
-const EMPTY_RECORD = { checks: [], commands: [], paths: [], edits: [], answer: '' };
-
function reportsOf(assets, { sessionId, turn, reasonKind, record }) {
return assets.map((asset) => immediateReportOf({
asset,
@@ -17,25 +14,24 @@ function reportsOf(assets, { sessionId, turn, reasonKind, record }) {
turn: turn ?? asset.turn,
reasonKind,
checks: record.checks,
- applied: appliedVerdictOf({ strategyText: asset.strategyText, ...record }).verdict,
}));
}
export function abortedReports(assets) {
return assets.map((asset) => ({
- report: reportsOf([asset], { sessionId: asset.sessionId, reasonKind: 'aborted', record: EMPTY_RECORD })[0],
+ report: reportsOf([asset], { sessionId: asset.sessionId, reasonKind: 'aborted', record: { checks: [] } })[0],
sessionId: asset.sessionId,
}));
}
-function rememberTurnCandidate({ sessionId, session, reasonKind, record, reusedAsset, model, ownerPid }) {
- if (skipReason(reasonKind, record, { reusedAsset })) return;
+function rememberTurnCandidate({ sessionId, session, reasonKind, record, model, ownerPid }) {
+ if (skipReason(reasonKind, record)) return;
rememberCandidate(sessionId, candidateOf({ prompt: session.prompt, record, model, turn: session.turn, pid: ownerPid() }));
}
/**
* Every injected strategy is reported when its turn ends, with the turn's own evidence; the turn itself becomes
- * the session's distillation candidate when it finished verified without reusing one. Returns the reuses the
+ * the session's distillation candidate when it finished verified. Returns the reuses the
* next prompt may label, and whether anything was queued. `ownerPid` is only asked when a candidate is written.
*/
function closeTurn({ sessionId, session, reasonKind, model, projectDir, ownerPid }) {
@@ -45,8 +41,7 @@ function closeTurn({ sessionId, session, reasonKind, model, projectDir, ownerPid
const reuses = reports.map(reportedReuseOf);
queueReports(reports.map((report) => ({ report, sessionId })));
rememberForRetention({ sessionId, projectDir, reuses, editedBefore: record.editedBefore });
- const reusedAsset = reuses.some((reuse) => reuse.applied === 'used');
- rememberTurnCandidate({ sessionId, session, reasonKind, record, reusedAsset, model, ownerPid });
+ rememberTurnCandidate({ sessionId, session, reasonKind, record, model, ownerPid });
return { reuses, queued: reports.length > 0 };
}
diff --git a/lib/turn-record.mjs b/lib/turn-record.mjs
index 240582c..9cbd2b2 100644
--- a/lib/turn-record.mjs
+++ b/lib/turn-record.mjs
@@ -5,9 +5,9 @@ import { appendStateLine, hashedName, removeState, statePath, takeStateLines } f
import { isVerificationCommand, shellCheckFailed } from './verification.mjs';
export const SHELL_TOOL = 'Shell';
-export const EDIT_TOOLS = new Set(['Write', 'Delete']);
+const DELETE_TOOL = 'Delete';
+export const EDIT_TOOLS = new Set(['Write', DELETE_TOOL]);
-const TURN_EDITED_MAX_CHARS = 200_000;
const CHECK_COMMAND_MAX_CHARS = 200;
const PATH_KEYS = ['path', 'file_path'];
const NOT_RUN_FAILURES = new Set(['permission_denied']);
@@ -55,17 +55,21 @@ function baseNote(input) {
};
}
-/** A tool call that ran, as postToolUse hands it over; Shell output is `{"output","exitCode"}` as JSON text. */
+/**
+ * A tool call that ran, as postToolUse hands it over; Shell output is `{"output","exitCode"}` as JSON text. A
+ * deleted file is an edit here, since afterFileEdit reports only writes.
+ */
export function noteOf({ tool_name: name, tool_input: input, tool_output: output }) {
const note = baseNote(input);
if (name === SHELL_TOOL && note.command) return { ...note, ...checkOf(note.command, parsedOutput(output)) };
+ if (name === DELETE_TOOL) return { ...note, edited: note.paths };
return note;
}
-/** An applied edit, as afterFileEdit hands it over: only the text it wrote in, not the whole file. */
-export function editNoteOf({ file_path: file, edits }) {
- const written = (Array.isArray(edits) ? edits : []).map((edit) => edit?.new_string).filter((text) => typeof text === 'string' && text);
- return { kind: 'edit', paths: typeof file === 'string' && file ? [file] : [], content: written.join('\n') };
+/** An applied edit, as afterFileEdit hands it over: the file it changed. */
+export function editNoteOf({ file_path: file }) {
+ const paths = typeof file === 'string' && file ? [file] : [];
+ return { kind: 'edit', paths, edited: paths };
}
/**
@@ -109,9 +113,7 @@ function addNote(record, note) {
if (typeof note.command === 'string') record.commands.push(note.command);
if (note.check) record.checks.push(note.check);
record.paths.push(...(note.paths ?? []));
- const editedChars = record.edits.reduce((total, edit) => total + edit.length, 0);
- const kept = String(note.content ?? '').slice(0, TURN_EDITED_MAX_CHARS - editedChars);
- if (kept) record.edits.push(kept);
+ record.edits.push(...(note.edited ?? []));
}
export function turnRecordOf(notes) {
diff --git a/test/applied.test.mjs b/test/applied.test.mjs
deleted file mode 100644
index ccce9b4..0000000
--- a/test/applied.test.mjs
+++ /dev/null
@@ -1,86 +0,0 @@
-import assert from 'node:assert/strict';
-import { readFileSync } from 'node:fs';
-import { test } from 'node:test';
-
-import { appliedVerdictOf, concreteFeaturesOf, strategyStepsOf } from '../lib/applied.mjs';
-
-const STRATEGY = [
- '[Evolution Memory] Fix flaky Redis connection in tests (EvoMap network):',
- '1. Set `maxRetriesPerRequest` to null in lib/redis.js',
- '2. Run npm test to confirm the suite passes',
- '3. Wrap the client in a singleton factory module',
- '4. Close the connection in the global teardown hook',
- '',
- 'Apply them where they fit.',
-].join('\n');
-
-test('only the numbered steps of an injected strategy are matched', () => {
- assert.equal(strategyStepsOf(STRATEGY).length, 4);
-});
-
-test('backticked names, paths and commands are concrete features', () => {
- assert.deepEqual(concreteFeaturesOf('Set `maxRetriesPerRequest` to null in lib/redis.js'), ['maxretriesperrequest', 'lib/redis.js']);
- assert.deepEqual(concreteFeaturesOf('Run npm test to confirm'), ['npm test']);
-});
-
-test('a turn that touched the named file and ran the named command used the strategy', () => {
- const verdict = appliedVerdictOf({
- strategyText: STRATEGY,
- commands: ['npm test'],
- paths: ['/repo/lib/redis.js'],
- answer: 'I wrapped the client in a singleton factory module.',
- });
- assert.equal(verdict.verdict, 'used');
- assert.equal(verdict.hits, 3);
- assert.equal(verdict.steps, 4);
-});
-
-test('one matching step out of four is partial', () => {
- const verdict = appliedVerdictOf({ strategyText: STRATEGY, commands: ['npm test'], paths: [], answer: 'Done.' });
- assert.equal(verdict.verdict, 'partial');
- assert.equal(verdict.ratio, 0.25);
-});
-
-test('a turn sharing nothing with the strategy left it unused', () => {
- const verdict = appliedVerdictOf({ strategyText: STRATEGY, commands: ['ls'], paths: ['/repo/README.md'], answer: 'Renamed the heading.' });
- assert.equal(verdict.verdict, 'unused');
- assert.equal(verdict.hits, 0);
-});
-
-test('generic words alone do not count as following a prose step', () => {
- const prose = '1. Make sure you always check the result before you report the outcome';
- assert.equal(appliedVerdictOf({ strategyText: prose, commands: ['ls'], paths: [], answer: 'make sure always check before report outcome' }).verdict, 'unused');
-});
-
-test('a strategy with no steps cannot be judged', () => {
- assert.deepEqual(appliedVerdictOf({ strategyText: '', commands: ['npm test'], paths: [], answer: '' }), { verdict: 'unknown', hits: 0, steps: 0, ratio: 0 });
-});
-
-const LRU_TURN = JSON.parse(readFileSync(new URL('./fixtures/lru-turn.json', import.meta.url), 'utf8'));
-
-test('a strategy carried out in the code the turn wrote is used, whatever language the answer is in', () => {
- const verdict = appliedVerdictOf(LRU_TURN);
- assert.equal(verdict.verdict, 'used');
- assert.equal(verdict.steps, 5);
- assert.ok(verdict.hits >= 3, `hits ${verdict.hits}`);
-});
-
-test('the same turn without its written code shows why edits must be matched', () => {
- assert.equal(appliedVerdictOf({ ...LRU_TURN, edits: [] }).hits, 0);
-});
-
-test('a prose word that reads like a command does not stop a step from matching on its terms', () => {
- const step = '1. On get, move the accessed node to the head of the list to mark it as most recently used';
- const edits = ['moveToHead(node) { if (node === this.head) return } // most recently used'];
- assert.equal(appliedVerdictOf({ strategyText: step, commands: [], paths: [], edits, answer: '' }).verdict, 'used');
-});
-
-test('work that shares nothing with the strategy is unused', () => {
- const verdict = appliedVerdictOf({ ...LRU_TURN, edits: ['export const greeting = "hello";\n'], commands: ['ls'], paths: ['/tmp/x/greeting.js'], answer: '好了。' });
- assert.equal(verdict.verdict, 'unused');
-});
-
-test('a turn that left only an answer, or a strategy without steps, cannot be judged', () => {
- assert.equal(appliedVerdictOf({ strategyText: LRU_TURN.strategyText, commands: [], paths: [], edits: [], answer: '用 Map 加双向链表即可。' }).verdict, 'unknown');
- assert.equal(appliedVerdictOf({ strategyText: 'no numbered steps here', commands: ['npm test'], paths: [], edits: [], answer: '' }).verdict, 'unknown');
-});
diff --git a/test/hooks.test.mjs b/test/hooks.test.mjs
index ceb59ad..632c6c2 100644
--- a/test/hooks.test.mjs
+++ b/test/hooks.test.mjs
@@ -128,7 +128,7 @@ test('session start adds nothing to the context when Evolver is set up', async (
assert.deepEqual(await session('s-quiet').start(), {});
});
-test("the turn end reports the strategy with the turn's own evidence and leaves no candidate for a reused one", async () => {
+test("the turn end reports the strategy as used with the turn's own evidence, and the verified turn that reused it is still a candidate", async () => {
await startSandbox();
const s = session('s-lru');
await lruTurn(s);
@@ -138,10 +138,10 @@ test("the turn end reports the strategy with the turn's own evidence and leaves
asset_id: ASSET_ID,
outcome: 'success',
task_id: 'cursor:s-lru:1',
- reason: 'Injected by Evolver into Cursor turn 1; applied: used; last test passed after failing earlier.',
+ reason: 'Injected by Evolver into Cursor turn 1; last test passed after failing earlier.',
evidence: { tier: 'L1', applied: 'used', check: { kind: 'test', passed: true, fail_then_pass: true }, aborted: false, stage: 'immediate' },
});
- assert.deepEqual(candidates(), {}, 'asset_reused');
+ assert.equal(candidates()['s-lru']?.prompt, LRU_PROMPT, 'a turn that reused a strategy is distilled too');
});
test('a non-zero exit fails a check even when its output reads clean, and postToolUseFailure fails it too', async () => {
@@ -229,8 +229,8 @@ test('a chat closed while its turn runs reports the strategy aborted, with an em
asset_id: ASSET_ID,
outcome: 'failed',
task_id: 'cursor:s-gone:1',
- reason: 'Injected by Evolver into Cursor turn 1; applied: unknown; no check ran.',
- evidence: { tier: 'none', applied: 'partial', aborted: true, stage: 'immediate' },
+ reason: 'Injected by Evolver into Cursor turn 1; no check ran.',
+ evidence: { tier: 'none', applied: 'used', aborted: true, stage: 'immediate' },
});
await new Promise((resolve) => setTimeout(resolve, 500));
assert.equal(reuseReports().length, 1);
@@ -296,7 +296,7 @@ test("a killed session's open strategies are reported aborted at the next sessio
const [report] = await waitFor(() => reuseReports());
assert.equal(report.asset_id, 'sha256:orphan');
assert.equal(report.task_id, 'cursor:s-killed:4');
- assert.deepEqual(report.evidence, { tier: 'none', applied: 'partial', aborted: true, stage: 'immediate' });
+ assert.deepEqual(report.evidence, { tier: 'none', applied: 'used', aborted: true, stage: 'immediate' });
await new Promise((resolve) => setTimeout(resolve, 300));
assert.equal(reuseReports().length, 1, "another host's ledger is left to it");
});
@@ -328,6 +328,18 @@ test('a verified turn becomes the candidate; a failed check leaves none, a later
assert.deepEqual(candidates(), {});
});
+test('a verified turn that reused an injected strategy is distilled at session end like any other', async () => {
+ await startSandbox();
+ const s = session('s-reused');
+ await lruTurn(s);
+ const [report] = await waitFor(() => reuseReports());
+ assert.equal(report.evidence.applied, 'used');
+ await s.end();
+ const [body] = await waitFor(() => proxy.of('/conversation/distill'));
+ assert.equal(body.user_prompt, LRU_PROMPT);
+ assert.deepEqual(body.execution.trace, [{ command: 'node --test', exit: 1 }, { command: 'node --test 2>&1 | tail -5', exit: 0 }]);
+});
+
test('a question that only asks for an opinion keeps the candidate', async () => {
await startSandbox({ assets: [] });
const s = session('s-ask');
diff --git a/test/session-distill.test.mjs b/test/session-distill.test.mjs
index 905b1b6..8c11897 100644
--- a/test/session-distill.test.mjs
+++ b/test/session-distill.test.mjs
@@ -43,7 +43,6 @@ test('only a verified, completed turn that used tools and reused no strategy bec
assert.equal(skipReason('completed', record({ answer: '' })), 'no_answer');
assert.equal(skipReason('completed', record({ checks: [{ command: 'npm test', failed: true }] })), 'failed_check');
assert.equal(skipReason('completed', record({ commands: [] })), 'no_tool_use');
- assert.equal(skipReason('completed', record(), { reusedAsset: true }), 'asset_reused');
});
test('the prompt is a digest of the turn, never its whole history, and asks for no answer values', () => {
diff --git a/test/turn-record.test.mjs b/test/turn-record.test.mjs
index 24c7991..a8c8964 100644
--- a/test/turn-record.test.mjs
+++ b/test/turn-record.test.mjs
@@ -5,7 +5,7 @@ import { editNoteOf, failureNoteOf, noteOf, turnRecordOf } from '../lib/turn-rec
const shell = (command, output, exitCode = 0) => noteOf({ tool_name: 'Shell', tool_input: { command, cwd: '/p' }, tool_output: JSON.stringify({ output, exitCode }) });
-test('a turn record keeps commands, checks with their verdict, touched paths, applied edits and the answer', () => {
+test('a turn record keeps commands, checks with their verdict, touched paths, edited files and the answer', () => {
const record = turnRecordOf([
{ kind: 'before', path: '/p/src/lru.js', hash: 'absent' },
shell('ls src', 'lru.js'),
@@ -20,7 +20,7 @@ test('a turn record keeps commands, checks with their verdict, touched paths, ap
]);
assert.deepEqual(record.commands, ['ls src', 'npm test', 'npm test']);
assert.deepEqual(record.checks, [{ command: 'npm test', failed: true }, { command: 'npm test', failed: false }]);
- assert.deepEqual(record.edits, ['export const cache = new Map();'], 'only what the edit wrote in, not the whole file');
+ assert.deepEqual(record.edits, ['/p/src/lru.js'], 'the files the turn edited');
assert.deepEqual(record.paths, ['/p/src/old.js', '/p/src/lru.js', '/p/src/lru.js']);
assert.deepEqual([...record.editedBefore], [['/p/src/lru.js', 'absent']]);
assert.equal(record.answer, 'Done.');
@@ -35,6 +35,11 @@ test('a failed Shell call fails its check unless the person interrupted it, and
assert.equal(failure({ failure_type: 'permission_denied' }), null);
});
+test('a deleted file counts as an edit, since afterFileEdit reports only writes', () => {
+ const record = turnRecordOf([noteOf({ tool_name: 'Delete', tool_input: { file_path: '/p/old.js' }, tool_output: '{}' })]);
+ assert.deepEqual(record.edits, ['/p/old.js']);
+});
+
test('Shell output that is not JSON is read as plain output', () => {
const [check] = turnRecordOf([noteOf({ tool_name: 'Shell', tool_input: { command: 'npm test' }, tool_output: 'Tests: 1 failed, 0 errors' })]).checks;
assert.deepEqual(check, { command: 'npm test', failed: true });
From 2ffa85698c6726ab089ec5c2c047e76f5d8314e1 Mon Sep 17 00:00:00 2001
From: forrestlinfeng
Date: Mon, 26 Oct 2026 14:29:59 +0800
Subject: [PATCH 3/7] test: retitle the candidate test now that reuse no longer
skips a turn
Co-Authored-By: Claude Opus 5.5
---
test/session-distill.test.mjs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/test/session-distill.test.mjs b/test/session-distill.test.mjs
index 8c11897..f901dba 100644
--- a/test/session-distill.test.mjs
+++ b/test/session-distill.test.mjs
@@ -36,7 +36,7 @@ function recordingProxy() {
return { sent, proxyFetch };
}
-test('only a verified, completed turn that used tools and reused no strategy becomes a candidate', () => {
+test('only a verified, completed turn that used tools becomes a candidate', () => {
assert.equal(skipReason('completed', record()), null);
assert.equal(skipReason('aborted', record()), 'not_completed');
assert.equal(skipReason('error', record()), 'not_completed');
From bb87a17eb968a575907fd66d78f00456c09ceed0 Mon Sep 17 00:00:00 2001
From: forrestlinfeng
Date: Mon, 26 Oct 2026 19:29:32 +0800
Subject: [PATCH 4/7] feat(status): lead /evolver-status with the tokens reused
strategies saved
A turn that reused an injected strategy and whose last check passed adds
its saving to the chat's and to the running total in
~/.evolver/state/cursor-savings.json (chats unseen for 8 days drop out of
the per-chat map, not out of the total). The saving is savingsCore's
reference-reuse estimate, as the Claude Code and Codex plugins show it,
from the lines the turn's edits changed, capped at the turn's own tokens
from stop (input + output + cache writes). Cursor hooks have no message
for the person only, so the figures are shown by /evolver-status, which
the plugin answers without the model.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 2 ++
README.md | 20 +++++++++++++--
commands/evolver-status.md | 2 +-
hooks/stop.mjs | 6 ++++-
lib/commands.mjs | 2 +-
lib/plugin-commands.mjs | 2 +-
lib/savings-ledger.mjs | 47 ++++++++++++++++++++++++++++++++++++
lib/savings.mjs | 20 +++++++++++++++
lib/status-report.mjs | 24 +++++++++++++++++-
lib/turn-end.mjs | 10 ++++----
lib/turn-record.mjs | 14 ++++++++---
test/hooks.test.mjs | 25 +++++++++++++++++--
test/savings-ledger.test.mjs | 35 +++++++++++++++++++++++++++
test/turn-record.test.mjs | 1 +
14 files changed, 192 insertions(+), 18 deletions(-)
create mode 100644 lib/savings-ledger.mjs
create mode 100644 lib/savings.mjs
create mode 100644 test/savings-ledger.test.mjs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d78f900..745584c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,8 @@ Aligned with the DeepSeek Harness plugin (dsh-evolver 0.3.0): Evolver stays out
- `/evolver-status`, `/evolver-search`, `/evolver-distill`, `/evolver-sync`, `/evolver-review`, `/evolver-run`
and `/evolver-solidify` are answered by the `beforeSubmitPrompt` hook itself, which stops the prompt before any
model sees it; `/evolver-evolve` is the one command left as a prompt.
+- `/evolver-status` opens with the tokens reused strategies saved in this chat and in all chats (the savingsCore
+ reference-reuse estimate, counted only after a passing check and capped at the turn's own tokens from `stop`).
- Evolver is installed and its Proxy started in the background when missing or older than 2.0.44.
### Removed
diff --git a/README.md b/README.md
index 4899292..5652b62 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ are answered by the plugin itself, before any model sees them.
| `postToolUse`, `postToolUseFailure` | Notes the turn's commands, checks (test, build, lint, type check) with pass or fail, and touched paths. |
| `afterFileEdit` | Notes the text each edit wrote in. |
| `afterAgentResponse` | Notes the turn's final answer. |
-| `stop` | Reports every strategy injected into the turn, with the turn's end as Cursor gives it (`completed`, `aborted`, `error`); makes the turn the chat's distillation candidate when it completed verified. Cursor sends the next prompt without waiting for this hook, so that prompt waits up to 3 s for the turn to settle before it labels the reuses or drops the candidate; a turn whose stop never comes is closed then as an error. |
+| `stop` | Counts what a reused strategy saved the turn (see [Tokens saved](#tokens-saved)) and reports every strategy injected into the turn, with the turn's end as Cursor gives it (`completed`, `aborted`, `error`); makes the turn the chat's distillation candidate when it completed verified. Cursor sends the next prompt without waiting for this hook, so that prompt waits up to 3 s for the turn to settle before it labels the reuses or drops the candidate; a turn whose stop never comes is closed then as an error. |
| `sessionEnd` (chat or window closed) | Reports a turn still running as aborted, and anything left unreported too; closes the candidate and starts its distillation in the background. Keeps the chat's turn counter, so a reopened chat never repeats a task id. |
Each hook does nothing when it runs inside the plugin's own headless distillation (`EVOLVER_HEADLESS=1`), which
@@ -73,7 +73,7 @@ one, the `beforeSubmitPrompt` hook runs it and stops the prompt with the answer
| Command | What it does |
| --- | --- |
-| `/evolver-status` | The Proxy, engine setup, the latest recall, recent reuse reports with the Hub's verdict, waiting candidates, claim link. |
+| `/evolver-status` | Leads with the tokens reused strategies saved in this chat and in all chats, then the Proxy, engine setup, the latest recall, recent reuse reports with the Hub's verdict, waiting candidates, claim link. |
| `/evolver-search ` | Searches the EvoMap network (`POST /asset/search`). |
| `/evolver-distill [focus]` | Distills this chat's latest verified turn now, through the Cursor agent CLI. |
| `/evolver-evolve [focus]` | An evolution checkpoint for the agent, dsh's text as is (Cursor appends the focus): the only command that is a prompt. It is recalled for like any prompt, and is no verdict on the turn before it, whose reuses wait for the next prompt. |
@@ -81,6 +81,22 @@ one, the `beforeSubmitPrompt` hook runs it and stops the prompt with the answer
Cursor shows a command's answer in the chat as a message the hook blocked the prompt with.
+## Tokens saved
+
+`/evolver-status` opens with what reusing EvoMap strategies saved:
+
+```text
+SAVED by reusing EvoMap strategies — this chat: ~33k tokens (≈$0.30) over 1 reused turn · all chats: ~124k tokens (≈$1.12) over 4 reused turns
+```
+
+A turn counts only when it reused an injected strategy and its last check passed. Its saving is the reference-reuse
+estimate of `@evomap/evolver-core`'s savingsCore (spec 0.3.0), the one the Claude Code and Codex plugins show, from
+the lines the turn's edits changed (`afterFileEdit`), capped at the tokens the turn spent itself: `stop`'s
+`input_tokens + output_tokens + cache_write_tokens`, without cache reads. Dollars use the same blended $9 per
+million tokens. Cursor's hooks have no message that reaches only the person (`stop` can only send the model a
+follow-up), so the figures are shown on demand rather than after each turn. They are kept in
+`~/.evolver/state/cursor-savings.json`: a running total, and per chat for chats seen in the last 8 days.
+
## Session-end distillation
A turn that ended `completed`, used tools and passed its last check leaves a candidate in `~/.evolver/state/cursor-distill-pending.json`; a later verified turn replaces it, and a next prompt
diff --git a/commands/evolver-status.md b/commands/evolver-status.md
index 9908b33..92d825a 100644
--- a/commands/evolver-status.md
+++ b/commands/evolver-status.md
@@ -1,5 +1,5 @@
---
-description: "Show Evolver health: the Proxy, engine setup, recent recalls, reuse reports and distillation candidates."
+description: "Show the tokens Evolver saved in this chat and in all chats, and its health: the Proxy, engine setup, recent recalls, reuse reports and distillation candidates."
---
The Evolver plugin answers `/evolver-status` itself, from its beforeSubmitPrompt hook, before this prompt reaches you.
diff --git a/hooks/stop.mjs b/hooks/stop.mjs
index c685930..3a792a0 100644
--- a/hooks/stop.mjs
+++ b/hooks/stop.mjs
@@ -3,6 +3,7 @@ import { DRAIN_TASK, startInBackground } from '../lib/background.mjs';
import { generationIdOf, runHook, sessionIdOf } from '../lib/hook-io.mjs';
import { hostProcess } from '../lib/process-owner.mjs';
import { projectDirOf } from '../lib/project-dir.mjs';
+import { freshTokensOf, recordSaving, turnSavingOf } from '../lib/savings-ledger.mjs';
import { modelOf } from '../lib/session-distill.mjs';
import { endOpenTurn } from '../lib/turn-end.mjs';
@@ -18,6 +19,9 @@ runHook((input) => {
projectDir: projectDirOf(input),
ownerPid: () => hostProcess()?.pid ?? null,
});
- if (ended?.queued) startInBackground([DRAIN_TASK]);
+ if (!ended) return {};
+ if (ended.queued) startInBackground([DRAIN_TASK]);
+ const saving = turnSavingOf({ ...ended, freshTokens: freshTokensOf(input) });
+ if (saving !== null) recordSaving(sessionId, saving);
return {};
});
diff --git a/lib/commands.mjs b/lib/commands.mjs
index b9970a9..cc5b5aa 100644
--- a/lib/commands.mjs
+++ b/lib/commands.mjs
@@ -154,7 +154,7 @@ export function evolverCommands({ status, search, distillNow, runCli = runEvolve
return [
{
name: `${NAME_PREFIX}status`,
- description: 'Show Evolver health: the Proxy, engine setup, recent recalls, reuse reports and distillation candidates.',
+ description: 'Show the tokens Evolver saved in this chat and in all chats, and its health: the Proxy, engine setup, recent recalls, reuse reports and distillation candidates.',
handler: async () => success(await status()),
},
{
diff --git a/lib/plugin-commands.mjs b/lib/plugin-commands.mjs
index 7744456..2002e08 100644
--- a/lib/plugin-commands.mjs
+++ b/lib/plugin-commands.mjs
@@ -10,7 +10,7 @@ const SEARCH_LIMIT = 5;
export function pluginCommands(sessionId) {
const proxyFetch = createProxyClient();
return evolverCommands({
- status: () => statusReport(proxyFetch),
+ status: () => statusReport(proxyFetch, sessionId),
search: (text) => proxyFetch('POST', '/asset/search', { text, limit: SEARCH_LIMIT }),
distillNow: (focus) => distillSession(sessionId, {
proxyFetch: createProxyClient({ timeoutMs: DISTILL_TIMEOUT_MS }),
diff --git a/lib/savings-ledger.mjs b/lib/savings-ledger.mjs
new file mode 100644
index 0000000..68c40db
--- /dev/null
+++ b/lib/savings-ledger.mjs
@@ -0,0 +1,47 @@
+import { OUTBOX_TTL_MS } from './report-outbox.mjs';
+import { referenceReuseSavings } from './savings.mjs';
+import { readState, statePath, updateState } from './state-file.mjs';
+
+const ledgerPath = () => statePath('cursor-savings.json');
+const NO_SAVINGS = { tokens: 0, turns: 0 };
+
+/** The tokens a turn spent itself, as Cursor's stop counts them: everything but what it read back from the cache. */
+export function freshTokensOf({ input_tokens: input, output_tokens: output, cache_write_tokens: cacheWrite }) {
+ if (!Number.isFinite(input) || !Number.isFinite(output)) return null;
+ return input + output + (Number.isFinite(cacheWrite) ? cacheWrite : 0);
+}
+
+/**
+ * What reusing an injected strategy saved a turn, or null when nothing is counted: a saving is claimed only for a
+ * reuse a passing check vouched for, and never above what the turn itself spent, since the estimator prices
+ * deriving a whole approach from scratch, which a single answered question never cost.
+ */
+export function turnSavingOf({ reuses, record, freshTokens }) {
+ const lastCheck = record.checks.at(-1);
+ if (reuses.length === 0 || !lastCheck || lastCheck.failed || freshTokens === null) return null;
+ return Math.min(referenceReuseSavings(record.changedLines), freshTokens);
+}
+
+const added = (savings = NO_SAVINGS, tokens) => ({ tokens: savings.tokens + tokens, turns: savings.turns + 1 });
+
+function recentSessions(sessions, now) {
+ return Object.fromEntries(Object.entries(sessions ?? {}).filter(([, entry]) => now - Number(entry?.at) <= OUTBOX_TTL_MS));
+}
+
+/** Adds a turn's saving to its chat's and to the running total; chats unseen for the outbox TTL are dropped. */
+export function recordSaving(sessionId, tokens, now = Date.now()) {
+ updateState(ledgerPath(), {}, (ledger) => {
+ const sessions = recentSessions(ledger.sessions, now);
+ return {
+ total: added(ledger.total, tokens),
+ sessions: { ...sessions, [sessionId]: { ...added(sessions[sessionId], tokens), at: now } },
+ };
+ });
+}
+
+/** The savings counted for one chat and for all chats so far. */
+export function savingsOf(sessionId) {
+ const ledger = readState(ledgerPath(), {});
+ const { tokens = 0, turns = 0 } = ledger.sessions?.[sessionId] ?? {};
+ return { session: { tokens, turns }, total: { ...NO_SAVINGS, ...ledger.total } };
+}
diff --git a/lib/savings.mjs b/lib/savings.mjs
new file mode 100644
index 0000000..ca63864
--- /dev/null
+++ b/lib/savings.mjs
@@ -0,0 +1,20 @@
+// The reuse estimator of @evomap/evolver-core's savingsCore (spec 0.3.0), as the Claude Code plugin ships it, so
+// a figure shown here reads the same as the EvoMap ledger's. An injected strategy is a reference reuse: the
+// model still writes the code, so only a fraction of deriving the approach from scratch is counted.
+const DERIVE_BASE_TOKENS = 120_000;
+const TOKENS_PER_CHANGED_LINE = 800;
+const DERIVE_CAP_TOKENS = 600_000;
+const TYPICAL_CHANGED_LINES = 75;
+const REFERENCE_SAVING_FRACTION = 0.4;
+const USD_PER_M_TOKENS_BLENDED = 9;
+
+export function referenceReuseSavings(changedLines) {
+ const isMeasured = Number.isFinite(changedLines) && changedLines > 0;
+ const lines = isMeasured ? changedLines : TYPICAL_CHANGED_LINES;
+ const derived = Math.min(DERIVE_BASE_TOKENS + lines * TOKENS_PER_CHANGED_LINE, DERIVE_CAP_TOKENS);
+ return Math.round(derived * REFERENCE_SAVING_FRACTION);
+}
+
+export function usdOf(tokens) {
+ return Math.round((tokens / 1_000_000) * USD_PER_M_TOKENS_BLENDED * 100) / 100;
+}
diff --git a/lib/status-report.mjs b/lib/status-report.mjs
index 3eb72c8..a4be883 100644
--- a/lib/status-report.mjs
+++ b/lib/status-report.mjs
@@ -8,6 +8,8 @@ import { readEngineState } from './engine-state.mjs';
import { MIN_EVOLVER_VERSION } from './engine-version.mjs';
import { describeProxyEndpoint } from './proxy.mjs';
import { readRecallLog } from './recall-log.mjs';
+import { savingsOf } from './savings-ledger.mjs';
+import { usdOf } from './savings.mjs';
import { outboxPath } from './report-queue.mjs';
import { pendingPath } from './session-distill.mjs';
import { readState } from './state-file.mjs';
@@ -69,6 +71,25 @@ function candidateLines(pending) {
];
}
+function compactTokens(tokens) {
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
+ if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
+ return String(tokens);
+}
+
+function savedPhrase({ tokens, turns }) {
+ if (turns === 0) return 'nothing yet';
+ return `~${compactTokens(tokens)} tokens (≈$${usdOf(tokens).toFixed(2)}) over ${turns} reused turn${turns === 1 ? '' : 's'}`;
+}
+
+// A reused turn counts only once a passing check vouched for it, and never for more than it spent itself.
+function savingsLines({ session, total }) {
+ return [
+ `SAVED by reusing EvoMap strategies — this chat: ${savedPhrase(session)} · all chats: ${savedPhrase(total)}`,
+ ' (estimated like the EvoMap ledger; counted only for reused turns whose last check passed)',
+ ];
+}
+
function claimLines() {
const claimUrlPath = path.join(os.homedir(), '.evomap', 'claim_url');
try {
@@ -83,10 +104,11 @@ function claimLines() {
}
}
-export async function statusReport(proxyFetch) {
+export async function statusReport(proxyFetch, sessionId) {
const proxy = await proxyFetch('GET', '/proxy/status');
return [
'Evolver Cursor plugin status',
+ ...savingsLines(savingsOf(sessionId)),
line('INFO', 'Node.js', process.version),
...proxyLines(proxy),
...engineLines(readEngineState()),
diff --git a/lib/turn-end.mjs b/lib/turn-end.mjs
index 333acf9..f22444b 100644
--- a/lib/turn-end.mjs
+++ b/lib/turn-end.mjs
@@ -31,23 +31,23 @@ function rememberTurnCandidate({ sessionId, session, reasonKind, record, model,
/**
* Every injected strategy is reported when its turn ends, with the turn's own evidence; the turn itself becomes
- * the session's distillation candidate when it finished verified. Returns the reuses the
- * next prompt may label, and whether anything was queued. `ownerPid` is only asked when a candidate is written.
+ * the session's distillation candidate when it finished verified. Returns the reuses the next prompt may label,
+ * whether anything was queued, and the turn's record. `ownerPid` is only asked when a candidate is written.
*/
function closeTurn({ sessionId, session, reasonKind, model, projectDir, ownerPid }) {
const record = takeTurnRecord(sessionId, session.turnId);
- if (!outcomeOfReason(reasonKind)) return { reuses: [], queued: false };
+ if (!outcomeOfReason(reasonKind)) return { reuses: [], queued: false, record };
const reports = reportsOf(takeUnreported(sessionId, session.turn), { sessionId, turn: session.turn, reasonKind, record });
const reuses = reports.map(reportedReuseOf);
queueReports(reports.map((report) => ({ report, sessionId })));
rememberForRetention({ sessionId, projectDir, reuses, editedBefore: record.editedBefore });
rememberTurnCandidate({ sessionId, session, reasonKind, record, model, ownerPid });
- return { reuses, queued: reports.length > 0 };
+ return { reuses, queued: reports.length > 0, record };
}
/**
* Closes the session's open turn (`turnId`, or any when omitted) as `reasonKind`, reports it, and settles it with
- * its reuses. Returns them, and whether anything was queued; null when no such turn was open.
+ * its reuses. Returns them, whether anything was queued and the turn's record; null when no such turn was open.
*/
export function endOpenTurn({ sessionId, turnId, reasonKind, model = null, projectDir, ownerPid = () => null }) {
const session = closeOpenTurn(sessionId, turnId);
diff --git a/lib/turn-record.mjs b/lib/turn-record.mjs
index 9cbd2b2..9f773b3 100644
--- a/lib/turn-record.mjs
+++ b/lib/turn-record.mjs
@@ -19,7 +19,7 @@ const UNSETTLED_SHELL_ERROR = 'Unknown error';
const recordPath = (sessionId, turnId) => statePath(hashedName('cursor-turn', `${sessionId}\n${turnId ?? ''}`, 'jsonl'));
function freshTurnRecord() {
- return { checks: [], commands: [], paths: [], edits: [], editedBefore: new Map(), answer: '' };
+ return { checks: [], commands: [], paths: [], edits: [], changedLines: 0, editedBefore: new Map(), answer: '' };
}
function editedPathOf(input) {
@@ -66,10 +66,15 @@ export function noteOf({ tool_name: name, tool_input: input, tool_output: output
return note;
}
-/** An applied edit, as afterFileEdit hands it over: the file it changed. */
-export function editNoteOf({ file_path: file }) {
+function lineCountOf(text) {
+ return typeof text === 'string' && text ? text.split('\n').length : 0;
+}
+
+/** An applied edit, as afterFileEdit hands it over: the file it changed and how many lines it took out or put in. */
+export function editNoteOf({ file_path: file, edits }) {
const paths = typeof file === 'string' && file ? [file] : [];
- return { kind: 'edit', paths, edited: paths };
+ const changedLines = (Array.isArray(edits) ? edits : []).reduce((total, change) => total + lineCountOf(change?.old_string) + lineCountOf(change?.new_string), 0);
+ return { kind: 'edit', paths, edited: paths, changedLines };
}
/**
@@ -114,6 +119,7 @@ function addNote(record, note) {
if (note.check) record.checks.push(note.check);
record.paths.push(...(note.paths ?? []));
record.edits.push(...(note.edited ?? []));
+ record.changedLines += note.changedLines ?? 0;
}
export function turnRecordOf(notes) {
diff --git a/test/hooks.test.mjs b/test/hooks.test.mjs
index 632c6c2..5d4a6b3 100644
--- a/test/hooks.test.mjs
+++ b/test/hooks.test.mjs
@@ -21,6 +21,7 @@ const LRU_SOURCE = 'export class LruCache {\n #entries = new Map();\n evict()
const FAILED_RUN = 'not ok 1 - evicts the oldest\nAssertionError: expected 1 to equal 2\n';
const PASSED_RUN = 'ℹ tests 2\nℹ pass 2\nℹ fail 0\n';
const LRU_PROMPT = '用 Node 写一个 LRU cache,带 get/set 和淘汰';
+const TURN_TOKENS = { input_tokens: 30_000, output_tokens: 2_000, cache_read_tokens: 50_000, cache_write_tokens: 1_000 };
let sandbox = null;
let proxy = null;
@@ -69,10 +70,10 @@ function session(id) {
await hook('after-file-edit', 'afterFileEdit', { file_path: path, edits: [{ old_string: '', new_string: content }] });
},
answer: (text) => hook('after-agent-response', 'afterAgentResponse', { text }),
- stop: (status = 'completed') => hook('stop', 'stop', { status, loop_count: 0 }),
+ stop: (status = 'completed') => hook('stop', 'stop', { status, loop_count: 0, ...TURN_TOKENS }),
finish: async (text, status = 'completed') => {
if (status === 'completed') await hook('after-agent-response', 'afterAgentResponse', { text });
- return hook('stop', 'stop', { status, loop_count: 0 });
+ return hook('stop', 'stop', { status, loop_count: 0, ...TURN_TOKENS });
},
end: (reason = 'user_close') => hook('session-end', 'sessionEnd', { reason, duration_ms: 1000, is_background_agent: false, final_status: 'completed' }),
};
@@ -526,3 +527,23 @@ test('a plugin command whose CLI cannot run answers its error in the chat instea
assert.equal(answer.continue, false);
assert.match(answer.user_message, /\S/);
});
+
+test('/evolver-status leads with the tokens reused strategies saved, in this chat and in all chats', async () => {
+ await startSandbox();
+ const s = session('s-saved');
+ await lruTurn(s);
+ const status = (await s.prompt('/evolver-status')).user_message.split('\n');
+ assert.equal(status[1], 'SAVED by reusing EvoMap strategies — this chat: ~33k tokens (≈$0.30) over 1 reused turn · all chats: ~33k tokens (≈$0.30) over 1 reused turn');
+
+ const other = session('s-other');
+ assert.match((await other.prompt('/evolver-status')).user_message, /this chat: nothing yet · all chats: ~33k tokens/);
+});
+
+test('a reused turn whose last check failed, or that ran none, saves nothing', async () => {
+ await startSandbox();
+ const s = session('s-unsaved');
+ await s.prompt(LRU_PROMPT);
+ await s.shell('npm test', FAILED_RUN, 1);
+ await s.finish('Still failing.');
+ assert.match((await s.prompt('/evolver-status')).user_message, /this chat: nothing yet · all chats: nothing yet/);
+});
diff --git a/test/savings-ledger.test.mjs b/test/savings-ledger.test.mjs
new file mode 100644
index 0000000..eef4c03
--- /dev/null
+++ b/test/savings-ledger.test.mjs
@@ -0,0 +1,35 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { freshTokensOf, recordSaving, savingsOf, turnSavingOf } from '../lib/savings-ledger.mjs';
+import { referenceReuseSavings } from '../lib/savings.mjs';
+import { freshHome } from './helpers/sandbox.mjs';
+
+const passed = { checks: [{ command: 'npm test', failed: false }], changedLines: 10 };
+
+test("a turn's fresh tokens are what Cursor's stop counts, without the cache reads", () => {
+ assert.equal(freshTokensOf({ input_tokens: 30_000, output_tokens: 2_000, cache_read_tokens: 50_000, cache_write_tokens: 1_000 }), 33_000);
+ assert.equal(freshTokensOf({ input_tokens: 30_000, output_tokens: 2_000 }), 32_000);
+ assert.equal(freshTokensOf({ status: 'completed' }), null);
+});
+
+test('a saving needs a reuse, a passing last check and known tokens, and never exceeds what the turn spent', () => {
+ const reuses = [{ assetId: 'a' }];
+ assert.equal(turnSavingOf({ reuses, record: passed, freshTokens: 1_000_000 }), referenceReuseSavings(10));
+ assert.equal(turnSavingOf({ reuses, record: passed, freshTokens: 5_000 }), 5_000);
+ assert.equal(turnSavingOf({ reuses: [], record: passed, freshTokens: 5_000 }), null);
+ assert.equal(turnSavingOf({ reuses, record: { checks: [{ command: 'npm test', failed: true }], changedLines: 0 }, freshTokens: 5_000 }), null);
+ assert.equal(turnSavingOf({ reuses, record: { checks: [], changedLines: 0 }, freshTokens: 5_000 }), null);
+ assert.equal(turnSavingOf({ reuses, record: passed, freshTokens: null }), null);
+});
+
+test('savings add up per chat and in total; a chat unseen for the outbox TTL leaves its chat entry but not the total', (t) => {
+ t.after(freshHome());
+ const day = 24 * 60 * 60 * 1000;
+ recordSaving('old', 10_000, 0);
+ recordSaving('new', 2_000, 9 * day);
+ recordSaving('new', 3_000, 9 * day);
+ assert.deepEqual(savingsOf('new'), { session: { tokens: 5_000, turns: 2 }, total: { tokens: 15_000, turns: 3 } });
+ assert.deepEqual(savingsOf('old').session, { tokens: 0, turns: 0 });
+ assert.deepEqual(savingsOf('never'), { session: { tokens: 0, turns: 0 }, total: { tokens: 15_000, turns: 3 } });
+});
diff --git a/test/turn-record.test.mjs b/test/turn-record.test.mjs
index a8c8964..647642d 100644
--- a/test/turn-record.test.mjs
+++ b/test/turn-record.test.mjs
@@ -21,6 +21,7 @@ test('a turn record keeps commands, checks with their verdict, touched paths, ed
assert.deepEqual(record.commands, ['ls src', 'npm test', 'npm test']);
assert.deepEqual(record.checks, [{ command: 'npm test', failed: true }, { command: 'npm test', failed: false }]);
assert.deepEqual(record.edits, ['/p/src/lru.js'], 'the files the turn edited');
+ assert.equal(record.changedLines, 3, 'the lines its edits took out or put in');
assert.deepEqual(record.paths, ['/p/src/old.js', '/p/src/lru.js', '/p/src/lru.js']);
assert.deepEqual([...record.editedBefore], [['/p/src/lru.js', 'absent']]);
assert.equal(record.answer, 'Done.');
From f5b3ef9c02ad38910412c7d0606d438be6e68dfc Mon Sep 17 00:00:00 2001
From: forrestlinfeng
Date: Mon, 26 Oct 2026 19:34:44 +0800
Subject: [PATCH 5/7] feat(state): keep plugin state for three days,
distillation candidates included
Undelivered reports, unreported injections, retention checks, chat files and
per-chat savings now live 3 days instead of 7-8 (one STATE_TTL_MS), and the
background worker drops distillation candidates older than that whoever owns
them, so a chat that never closes on a host without a process table no longer
keeps its candidate for good.
README: Cursor 3.27 loads plugin hooks only while "Include Third-Party Plugins,
Skills, and Other Configs" is on; with it off none of this plugin's hooks run.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 2 ++
README.md | 8 ++++++--
lib/injected-assets.mjs | 4 ++--
lib/report-outbox.mjs | 6 ++++--
lib/retention-pending.mjs | 4 ++--
lib/savings-ledger.mjs | 4 ++--
lib/session-distill.mjs | 12 ++++++++++++
lib/session-state.mjs | 4 ++--
scripts/background-worker.mjs | 3 ++-
test/hooks.test.mjs | 4 ++--
test/report-outbox.test.mjs | 6 +++---
test/savings-ledger.test.mjs | 4 ++--
test/session-distill.test.mjs | 12 +++++++++++-
13 files changed, 52 insertions(+), 21 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 745584c..b5b08e5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -31,6 +31,8 @@ Aligned with the DeepSeek Harness plugin (dsh-evolver 0.3.0): Evolver stays out
model sees it; `/evolver-evolve` is the one command left as a prompt.
- `/evolver-status` opens with the tokens reused strategies saved in this chat and in all chats (the savingsCore
reference-reuse estimate, counted only after a passing check and capped at the turn's own tokens from `stop`).
+- Everything the plugin keeps in `~/.evolver/state` lives at most 3 days (undelivered reports, unreported
+ injections, retention checks, chat files, per-chat savings, distillation candidates).
- Evolver is installed and its Proxy started in the background when missing or older than 2.0.44.
### Removed
diff --git a/README.md b/README.md
index 5652b62..72d9246 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,10 @@ Reload the window; Settings → Hooks lists the plugin's hooks.
- Cursor with hooks (3.x). `beforeSubmitPrompt`'s `additional_context`, which carries the recalled strategy, is
read by Cursor 3.27 but not yet in Cursor's hook docs.
+- Cursor's **Include Third-Party Plugins, Skills, and Other Configs** setting on (the default). Cursor 3.27 loads a
+ plugin's hooks only while it is on (`loadPluginHooks` returns early otherwise), so with it off the plugin is listed
+ but none of its hooks runs. The same setting also imports Claude Code hooks from `~/.claude/settings.json`; if those
+ include Evolver's own (`evolver inject prompt-recall`), a prompt is recalled for twice.
- Node.js 20 or newer on the `PATH` Cursor's hooks see.
- Evolver 2.0.44 or newer. The plugin installs it under `~/.evolver/state/cursor-engine` and starts its Proxy in
the background when it is missing; `npm install -g @evomap/evolver@latest` works too.
@@ -95,7 +99,7 @@ the lines the turn's edits changed (`afterFileEdit`), capped at the tokens the t
`input_tokens + output_tokens + cache_write_tokens`, without cache reads. Dollars use the same blended $9 per
million tokens. Cursor's hooks have no message that reaches only the person (`stop` can only send the model a
follow-up), so the figures are shown on demand rather than after each turn. They are kept in
-`~/.evolver/state/cursor-savings.json`: a running total, and per chat for chats seen in the last 8 days.
+`~/.evolver/state/cursor-savings.json`: a running total, and per chat for chats seen in the last 3 days.
## Session-end distillation
@@ -145,7 +149,7 @@ Every strategy injected into a turn is reported through the Proxy's `POST /asset
either where Cursor fails it with `Unknown error`; its streaming Shell executor instead reports such a command as
exit code 0 with the output so far and nothing to tell it apart, so there the output alone decides.
- **Delivery.** Reports wait in `~/.evolver/state/cursor-reports-outbox.json` and are retried by the background
- worker at every turn end, follow-up prompt and session start for 8 days. One the Hub refuses as invalid is
+ worker at every turn end, follow-up prompt and session start for 3 days. One the Hub refuses as invalid is
settled as rejected and never resent. Reports with evidence are also written once to the local root event log.
- Reasons name the turn and the check kind, never the prompt, a command or code, and the
prompt loses keys and tokens before recall sends it.
diff --git a/lib/injected-assets.mjs b/lib/injected-assets.mjs
index f80eb55..f8de32b 100644
--- a/lib/injected-assets.mjs
+++ b/lib/injected-assets.mjs
@@ -2,15 +2,15 @@ import fs from 'node:fs';
import path from 'node:path';
import { ownerIsGone } from './process-owner.mjs';
+import { STATE_TTL_MS } from './report-outbox.mjs';
import { hashedName, readState, removeState, stateDir, statePath, updateState } from './state-file.mjs';
-const ENTRY_PRUNE_MS = 7 * 24 * 60 * 60 * 1000;
const LEDGER_PREFIX = 'cursor-injected';
function pruned(ledger, now) {
return Object.fromEntries(Object.entries(ledger ?? {}).filter(([, entry]) => {
const at = Number(entry?.injectedAt);
- return Number.isFinite(at) && now - at <= ENTRY_PRUNE_MS;
+ return Number.isFinite(at) && now - at <= STATE_TTL_MS;
}));
}
diff --git a/lib/report-outbox.mjs b/lib/report-outbox.mjs
index e30d438..deea324 100644
--- a/lib/report-outbox.mjs
+++ b/lib/report-outbox.mjs
@@ -1,9 +1,11 @@
-export const OUTBOX_TTL_MS = 8 * 24 * 60 * 60 * 1000;
+// Everything this plugin keeps in ~/.evolver/state lives at most this long: undelivered reports, injections no turn
+// end reported, retention checks, chat files, savings per chat and distillation candidates.
+export const STATE_TTL_MS = 3 * 24 * 60 * 60 * 1000;
const keyOf = (report) => `${report.task_id}\0${report.asset_id}`;
function pruned(outbox, now) {
- return Object.fromEntries(Object.entries(outbox ?? {}).filter(([, entry]) => now - Number(entry?.queuedAt) <= OUTBOX_TTL_MS));
+ return Object.fromEntries(Object.entries(outbox ?? {}).filter(([, entry]) => now - Number(entry?.queuedAt) <= STATE_TTL_MS));
}
export function withQueued(outbox, queued, now) {
diff --git a/lib/retention-pending.mjs b/lib/retention-pending.mjs
index a3ffad9..138fb53 100644
--- a/lib/retention-pending.mjs
+++ b/lib/retention-pending.mjs
@@ -1,7 +1,7 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
-import { OUTBOX_TTL_MS } from './report-outbox.mjs';
+import { STATE_TTL_MS } from './report-outbox.mjs';
import { ABSENT, retentionOf } from './retention.mjs';
import { retentionReportOf } from './reuse.mjs';
import { appendStateLine, statePath, takeStateLines } from './state-file.mjs';
@@ -24,7 +24,7 @@ export function rememberForRetention({ sessionId, projectDir, reuses, editedBefo
export function takeDueRetentions({ sessionId, projectDir, now = Date.now() }) {
const { lines, commit } = takeStateLines(pendingPath(), now);
- const pending = lines.filter((entry) => now - entry.atMs <= OUTBOX_TTL_MS);
+ const pending = lines.filter((entry) => now - entry.atMs <= STATE_TTL_MS);
const isDue = (entry) => entry.projectDir === projectDir && entry.sessionId !== sessionId;
for (const entry of pending.filter((candidate) => !isDue(candidate))) appendStateLine(pendingPath(), entry);
return { due: pending.filter(isDue), commit };
diff --git a/lib/savings-ledger.mjs b/lib/savings-ledger.mjs
index 68c40db..838ceed 100644
--- a/lib/savings-ledger.mjs
+++ b/lib/savings-ledger.mjs
@@ -1,4 +1,4 @@
-import { OUTBOX_TTL_MS } from './report-outbox.mjs';
+import { STATE_TTL_MS } from './report-outbox.mjs';
import { referenceReuseSavings } from './savings.mjs';
import { readState, statePath, updateState } from './state-file.mjs';
@@ -25,7 +25,7 @@ export function turnSavingOf({ reuses, record, freshTokens }) {
const added = (savings = NO_SAVINGS, tokens) => ({ tokens: savings.tokens + tokens, turns: savings.turns + 1 });
function recentSessions(sessions, now) {
- return Object.fromEntries(Object.entries(sessions ?? {}).filter(([, entry]) => now - Number(entry?.at) <= OUTBOX_TTL_MS));
+ return Object.fromEntries(Object.entries(sessions ?? {}).filter(([, entry]) => now - Number(entry?.at) <= STATE_TTL_MS));
}
/** Adds a turn's saving to its chat's and to the running total; chats unseen for the outbox TTL are dropped. */
diff --git a/lib/session-distill.mjs b/lib/session-distill.mjs
index 7876b2d..055c58e 100644
--- a/lib/session-distill.mjs
+++ b/lib/session-distill.mjs
@@ -1,4 +1,5 @@
import { ownerIsGone } from './process-owner.mjs';
+import { STATE_TTL_MS } from './report-outbox.mjs';
import { withoutSecrets } from './redact.mjs';
import { readState, statePath, updateState } from './state-file.mjs';
@@ -93,6 +94,17 @@ function isUnclaimed(candidate, isGone) {
return !candidate?.claimedBy || isGone(candidate.claimedBy);
}
+/**
+ * Drops candidates written more than the state TTL ago, whoever owns them: a chat that never closes on a host with
+ * no process table would otherwise keep its candidate for good.
+ */
+export function dropStaleCandidates(now = Date.now()) {
+ updateState(pendingPath(), {}, (pending) => {
+ const fresh = Object.fromEntries(Object.entries(pending).filter(([, candidate]) => now - Number(candidate?.at) <= STATE_TTL_MS));
+ return Object.keys(fresh).length === Object.keys(pending).length ? undefined : fresh;
+ });
+}
+
/** This session's own candidate, if it has one. Left on disk until settled. */
export function dueCandidates({ sessionId }) {
const candidate = readState(pendingPath(), {})[sessionId];
diff --git a/lib/session-state.mjs b/lib/session-state.mjs
index a5f056d..cdc88b3 100644
--- a/lib/session-state.mjs
+++ b/lib/session-state.mjs
@@ -1,7 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { OUTBOX_TTL_MS } from './report-outbox.mjs';
+import { STATE_TTL_MS } from './report-outbox.mjs';
import { hashedName, readState, stateDir, statePath, updateState } from './state-file.mjs';
const SESSION_FILE = /^cursor-(?:session|turn|injected)-[0-9a-f]{16}\.jsonl?$/;
@@ -87,7 +87,7 @@ export function pruneOldSessions(now = Date.now()) {
for (const name of names.filter((candidate) => SESSION_FILE.test(candidate))) {
const file = path.join(stateDir(), name);
try {
- if (now - fs.statSync(file).mtimeMs > OUTBOX_TTL_MS) fs.rmSync(file, { force: true });
+ if (now - fs.statSync(file).mtimeMs > STATE_TTL_MS) fs.rmSync(file, { force: true });
} catch {
}
}
diff --git a/scripts/background-worker.mjs b/scripts/background-worker.mjs
index 25b7336..534d13b 100644
--- a/scripts/background-worker.mjs
+++ b/scripts/background-worker.mjs
@@ -7,6 +7,7 @@ import { engineRuntime } from '../lib/engine-runtime.mjs';
import { recordReuseLocally } from '../lib/local-ledger.mjs';
import { createProxyClient } from '../lib/proxy.mjs';
import { drainOutbox } from '../lib/report-queue.mjs';
+import { dropStaleCandidates } from '../lib/session-distill.mjs';
import { pruneOldSessions } from '../lib/session-state.mjs';
async function ranCleanly(work) {
@@ -19,7 +20,7 @@ async function ranCleanly(work) {
}
const tasks = new Set(process.argv.slice(2));
-const results = [await ranCleanly(() => pruneOldSessions())];
+const results = [await ranCleanly(() => pruneOldSessions()), await ranCleanly(() => dropStaleCandidates())];
if (tasks.has(ENGINE_TASK)) results.push(await ranCleanly(() => setUpEngine(engineRuntime())));
if (tasks.has(DRAIN_TASK)) {
results.push(await ranCleanly(() => drainOutbox({ proxyFetch: createProxyClient(), recordLocally: recordReuseLocally })));
diff --git a/test/hooks.test.mjs b/test/hooks.test.mjs
index 5d4a6b3..34b03dc 100644
--- a/test/hooks.test.mjs
+++ b/test/hooks.test.mjs
@@ -395,8 +395,8 @@ test("a killed session's candidate is claimed and distilled by the next session
await startSandbox({ assets: [] });
const candidate = (pid, at) => ({ prompt: 'p', commands: ['npm test'], paths: [], checks: [], answer: 'a', model: { model: 'm' }, turn: 1, pid, attempts: 0, at });
mkdirSync(statePath(''), { recursive: true });
- writeFileSync(statePath('cursor-distill-pending.json'), JSON.stringify({ 's-killed': candidate(deadPid(), 1), 's-idle': candidate(process.pid, 2) }));
- writeFileSync(statePath('codex-distill-pending.json'), JSON.stringify({ 's-codex': candidate(deadPid(), 3) }));
+ writeFileSync(statePath('cursor-distill-pending.json'), JSON.stringify({ 's-killed': candidate(deadPid(), Date.now() - 2), 's-idle': candidate(process.pid, Date.now() - 1) }));
+ writeFileSync(statePath('codex-distill-pending.json'), JSON.stringify({ 's-codex': candidate(deadPid(), Date.now()) }));
await session('s-next').start();
await waitFor(() => proxy.of('/conversation/distill'));
diff --git a/test/report-outbox.test.mjs b/test/report-outbox.test.mjs
index 77eb56c..fe4a806 100644
--- a/test/report-outbox.test.mjs
+++ b/test/report-outbox.test.mjs
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
-import { OUTBOX_TTL_MS, isSettled, withAttempt, withQueued } from '../lib/report-outbox.mjs';
+import { STATE_TTL_MS, isSettled, withAttempt, withQueued } from '../lib/report-outbox.mjs';
const report = (taskId, assetId = 'sha256:a') => ({ asset_id: assetId, task_id: taskId, outcome: 'success', evidence: { tier: 'L3' } });
@@ -13,9 +13,9 @@ test('the same report is queued once, keyed by task id and asset', () => {
assert.equal(Object.keys(withQueued(twice, [{ report: report('t:1:correction', 'sha256:b'), sessionId: 's' }], 3)).length, 2);
});
-test('reports older than eight days are dropped', () => {
+test('reports older than three days are dropped', () => {
const queued = withQueued({}, [{ report: report('t:1'), sessionId: 's' }], 0);
- assert.deepEqual(withQueued(queued, [], OUTBOX_TTL_MS + 1), {});
+ assert.deepEqual(withQueued(queued, [], STATE_TTL_MS + 1), {});
});
test('each delivery attempt is counted', () => {
diff --git a/test/savings-ledger.test.mjs b/test/savings-ledger.test.mjs
index eef4c03..098e820 100644
--- a/test/savings-ledger.test.mjs
+++ b/test/savings-ledger.test.mjs
@@ -27,8 +27,8 @@ test('savings add up per chat and in total; a chat unseen for the outbox TTL lea
t.after(freshHome());
const day = 24 * 60 * 60 * 1000;
recordSaving('old', 10_000, 0);
- recordSaving('new', 2_000, 9 * day);
- recordSaving('new', 3_000, 9 * day);
+ recordSaving('new', 2_000, 4 * day);
+ recordSaving('new', 3_000, 4 * day);
assert.deepEqual(savingsOf('new'), { session: { tokens: 5_000, turns: 2 }, total: { tokens: 15_000, turns: 3 } });
assert.deepEqual(savingsOf('old').session, { tokens: 0, turns: 0 });
assert.deepEqual(savingsOf('never'), { session: { tokens: 0, turns: 0 }, total: { tokens: 15_000, turns: 3 } });
diff --git a/test/session-distill.test.mjs b/test/session-distill.test.mjs
index f901dba..3340acc 100644
--- a/test/session-distill.test.mjs
+++ b/test/session-distill.test.mjs
@@ -4,7 +4,7 @@ import { test } from 'node:test';
import { distillDue, distillSession } from '../lib/distiller.mjs';
import {
argumentsOf, candidateOf, claimLeftoverCandidates, claimSessionCandidate, closeCandidate, distillCandidate, distillPrompt,
- dropCandidate, dueCandidates, isCurrentCandidate, rememberCandidate, settleCandidate, skipReason,
+ dropCandidate, dropStaleCandidates, dueCandidates, isCurrentCandidate, rememberCandidate, settleCandidate, skipReason,
} from '../lib/session-distill.mjs';
import { freshHome } from './helpers/sandbox.mjs';
@@ -178,3 +178,13 @@ test('keys and tokens never reach the candidate, nor the prompt a manual focus w
assert.doesNotMatch(JSON.stringify(candidate), new RegExp(secret));
assert.doesNotMatch(distillPrompt(candidate, `Authorization: Bearer ${secret}`), new RegExp(secret));
});
+
+test('a candidate older than three days is dropped whoever owns it; a fresher one stays', (t) => {
+ t.after(freshHome());
+ const day = 24 * 60 * 60 * 1000;
+ rememberCandidate('stuck', { pid: null, at: 0 });
+ rememberCandidate('fresh', { pid: null, at: 2 * day });
+ dropStaleCandidates(4 * day);
+ assert.deepEqual(dueCandidates({ sessionId: 'stuck' }), []);
+ assert.equal(dueCandidates({ sessionId: 'fresh' }).length, 1);
+});
From e724b2d0480bde1aee6d833ad1c4424fc6a8a378 Mon Sep 17 00:00:00 2001
From: forrestlinfeng
Date: Mon, 26 Oct 2026 19:57:25 +0800
Subject: [PATCH 6/7] feat(status): list this chat's reused assets under the
SAVED line
Each turn that reused an injected strategy is recorded with its turn
number, the asset's name and id, the turn's last check and its saving
(or none counted), and /evolver-status lists a chat's latest 20 below
the totals. The injection ledger keeps the recalled asset's name so the
turn end can show it.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 3 ++-
README.md | 6 +++++-
hooks/stop.mjs | 4 ++--
hooks/user-prompt-submit.mjs | 2 +-
lib/injected-assets.mjs | 12 ++++++++----
lib/prime.mjs | 3 ++-
lib/savings-ledger.mjs | 36 ++++++++++++++++++++++++++++--------
lib/status-report.mjs | 12 +++++++++++-
lib/turn-end.mjs | 9 +++++----
test/hooks.test.mjs | 1 +
test/prime.test.mjs | 6 +++---
test/savings-ledger.test.mjs | 35 ++++++++++++++++++++++++++++-------
12 files changed, 96 insertions(+), 33 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b5b08e5..6015b7c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,7 +29,8 @@ Aligned with the DeepSeek Harness plugin (dsh-evolver 0.3.0): Evolver stays out
- `/evolver-status`, `/evolver-search`, `/evolver-distill`, `/evolver-sync`, `/evolver-review`, `/evolver-run`
and `/evolver-solidify` are answered by the `beforeSubmitPrompt` hook itself, which stops the prompt before any
model sees it; `/evolver-evolve` is the one command left as a prompt.
-- `/evolver-status` opens with the tokens reused strategies saved in this chat and in all chats (the savingsCore
+- `/evolver-status` opens with the tokens reused strategies saved in this chat and in all chats, then lists this
+ chat's reused assets (turn, name and id, last check, saving) (the savingsCore
reference-reuse estimate, counted only after a passing check and capped at the turn's own tokens from `stop`).
- Everything the plugin keeps in `~/.evolver/state` lives at most 3 days (undelivered reports, unreported
injections, retention checks, chat files, per-chat savings, distillation candidates).
diff --git a/README.md b/README.md
index 72d9246..8cbde94 100644
--- a/README.md
+++ b/README.md
@@ -91,15 +91,19 @@ Cursor shows a command's answer in the chat as a message the hook blocked the pr
```text
SAVED by reusing EvoMap strategies — this chat: ~33k tokens (≈$0.30) over 1 reused turn · all chats: ~124k tokens (≈$1.12) over 4 reused turns
+ turn 3 · "Guide Xiaohongshu operations from account positioning…" (sha256:3fccd18…) · last check passed · ~33k saved
```
+Below it, one line for each strategy this chat reused (the latest 20): the turn, the asset's name and id, how the
+turn's last check went, and what was saved, or `no saving counted`.
+
A turn counts only when it reused an injected strategy and its last check passed. Its saving is the reference-reuse
estimate of `@evomap/evolver-core`'s savingsCore (spec 0.3.0), the one the Claude Code and Codex plugins show, from
the lines the turn's edits changed (`afterFileEdit`), capped at the tokens the turn spent itself: `stop`'s
`input_tokens + output_tokens + cache_write_tokens`, without cache reads. Dollars use the same blended $9 per
million tokens. Cursor's hooks have no message that reaches only the person (`stop` can only send the model a
follow-up), so the figures are shown on demand rather than after each turn. They are kept in
-`~/.evolver/state/cursor-savings.json`: a running total, and per chat for chats seen in the last 3 days.
+`~/.evolver/state/cursor-savings.json`: a running total, and per chat (with its reuses) for chats seen in the last 3 days.
## Session-end distillation
diff --git a/hooks/stop.mjs b/hooks/stop.mjs
index 3a792a0..9962d7d 100644
--- a/hooks/stop.mjs
+++ b/hooks/stop.mjs
@@ -3,7 +3,7 @@ import { DRAIN_TASK, startInBackground } from '../lib/background.mjs';
import { generationIdOf, runHook, sessionIdOf } from '../lib/hook-io.mjs';
import { hostProcess } from '../lib/process-owner.mjs';
import { projectDirOf } from '../lib/project-dir.mjs';
-import { freshTokensOf, recordSaving, turnSavingOf } from '../lib/savings-ledger.mjs';
+import { freshTokensOf, recordReuseTurn, turnSavingOf } from '../lib/savings-ledger.mjs';
import { modelOf } from '../lib/session-distill.mjs';
import { endOpenTurn } from '../lib/turn-end.mjs';
@@ -22,6 +22,6 @@ runHook((input) => {
if (!ended) return {};
if (ended.queued) startInBackground([DRAIN_TASK]);
const saving = turnSavingOf({ ...ended, freshTokens: freshTokensOf(input) });
- if (saving !== null) recordSaving(sessionId, saving);
+ recordReuseTurn(sessionId, { turn: ended.turn, reused: ended.reused, lastCheck: ended.record.checks.at(-1), saving });
return {};
});
diff --git a/hooks/user-prompt-submit.mjs b/hooks/user-prompt-submit.mjs
index 17f5611..f08588c 100644
--- a/hooks/user-prompt-submit.mjs
+++ b/hooks/user-prompt-submit.mjs
@@ -58,7 +58,7 @@ async function recalledStrategy(sessionId, turn, prompt) {
});
const [assetId] = match.ids;
if (!assetId) return '';
- rememberInjected(sessionId, assetId, { turn, strategyText: match.text, pid: hostProcess()?.pid ?? null });
+ rememberInjected(sessionId, assetId, { turn, strategyText: match.text, title: match.title, pid: hostProcess()?.pid ?? null });
return match.text;
}
diff --git a/lib/injected-assets.mjs b/lib/injected-assets.mjs
index f8de32b..70909a1 100644
--- a/lib/injected-assets.mjs
+++ b/lib/injected-assets.mjs
@@ -22,16 +22,20 @@ function pruned(ledger, now) {
const owned = (ledger, sessionId, pid) =>
Object.fromEntries(Object.entries(ledger).map(([id, entry]) => [id, { ...entry, sessionId, pid }]));
-export function withInjected(ledger, assetId, { turn, strategyText, sessionId, pid }, now) {
+export function withInjected(ledger, assetId, { turn, strategyText, title, sessionId, pid }, now) {
const kept = owned(pruned(ledger, now), sessionId, pid);
if (kept[assetId]) return kept;
- return { ...kept, [assetId]: { turn, injectedAt: now, strategyText, sessionId, pid } };
+ return { ...kept, [assetId]: { turn, injectedAt: now, strategyText, title, sessionId, pid } };
}
+const injectionOf = (assetId, entry) => ({
+ assetId, strategyText: entry.strategyText ?? '', title: entry.title || assetId, turn: entry.turn, sessionId: entry.sessionId,
+});
+
export function unreportedOf(ledger, turn) {
return Object.entries(ledger ?? {})
.filter(([, entry]) => !entry?.reportedAt && Number(entry?.turn) <= turn)
- .map(([assetId, entry]) => ({ assetId, strategyText: entry.strategyText ?? '', turn: entry.turn, sessionId: entry.sessionId }));
+ .map(([assetId, entry]) => injectionOf(assetId, entry));
}
export function withReported(ledger, assetIds, now) {
@@ -80,7 +84,7 @@ export function queueOrphanedUnreported(queue, { isGone = ownerIsGone, now = Dat
updateState(path.join(stateDir(), file), {}, (ledger) => {
const due = Object.entries(ledger)
.filter(([, entry]) => !entry?.reportedAt && entry?.sessionId && isGone(entry.pid))
- .map(([assetId, entry]) => ({ assetId, strategyText: entry.strategyText ?? '', turn: entry.turn, sessionId: entry.sessionId }));
+ .map(([assetId, entry]) => injectionOf(assetId, entry));
if (due.length === 0) return undefined;
queue(due);
return withReported(ledger, due.map((asset) => asset.assetId), now);
diff --git a/lib/prime.mjs b/lib/prime.mjs
index 4187633..785e498 100644
--- a/lib/prime.mjs
+++ b/lib/prime.mjs
@@ -22,7 +22,7 @@ const DEFAULT_MIN_SIMILARITY = 0.3;
const MIN_STRATEGY_STEPS = 4;
export const STRATEGY_MAX_CHARS = 4000;
const UNSCORED = -1;
-const EMPTY_MATCH = { ids: [], text: '' };
+const EMPTY_MATCH = { ids: [], text: '', title: '' };
function strategySteps(asset) {
const steps = asset?.strategy ?? asset?.payload?.strategy ?? asset?.gene?.strategy;
@@ -162,6 +162,7 @@ export async function hubGene(proxyFetch, text, {
});
return {
ids: [candidate.asset_id],
+ title: readableNameOf(candidate),
text: [
`[Evolution Memory] ${readableNameOf(candidate)} (EvoMap network):`,
...steps.map((step, index) => `${index + 1}. ${step}`),
diff --git a/lib/savings-ledger.mjs b/lib/savings-ledger.mjs
index 838ceed..a4def4c 100644
--- a/lib/savings-ledger.mjs
+++ b/lib/savings-ledger.mjs
@@ -4,6 +4,7 @@ import { readState, statePath, updateState } from './state-file.mjs';
const ledgerPath = () => statePath('cursor-savings.json');
const NO_SAVINGS = { tokens: 0, turns: 0 };
+const MAX_LISTED_REUSES = 20;
/** The tokens a turn spent itself, as Cursor's stop counts them: everything but what it read back from the cache. */
export function freshTokensOf({ input_tokens: input, output_tokens: output, cache_write_tokens: cacheWrite }) {
@@ -22,26 +23,45 @@ export function turnSavingOf({ reuses, record, freshTokens }) {
return Math.min(referenceReuseSavings(record.changedLines), freshTokens);
}
-const added = (savings = NO_SAVINGS, tokens) => ({ tokens: savings.tokens + tokens, turns: savings.turns + 1 });
+function added(savings, saving) {
+ const { tokens, turns } = { ...NO_SAVINGS, ...savings };
+ return saving === null ? { tokens, turns } : { tokens: tokens + saving, turns: turns + 1 };
+}
+
+function checkVerdictOf(lastCheck) {
+ if (!lastCheck) return 'none';
+ return lastCheck.failed ? 'failed' : 'passed';
+}
+
+function reusesOf({ turn, reused, lastCheck, saving }) {
+ return reused.map(({ assetId, title }) => ({ turn, assetId, title, check: checkVerdictOf(lastCheck), saved: saving }));
+}
function recentSessions(sessions, now) {
return Object.fromEntries(Object.entries(sessions ?? {}).filter(([, entry]) => now - Number(entry?.at) <= STATE_TTL_MS));
}
-/** Adds a turn's saving to its chat's and to the running total; chats unseen for the outbox TTL are dropped. */
-export function recordSaving(sessionId, tokens, now = Date.now()) {
+/**
+ * Records a turn that reused injected strategies: each asset with the turn's last check, and the turn's saving
+ * (null when none is counted) added to its chat's and to the running total. A chat keeps its latest reuses only,
+ * and chats unseen for the state TTL are dropped.
+ */
+export function recordReuseTurn(sessionId, turn, now = Date.now()) {
+ if (turn.reused.length === 0) return;
updateState(ledgerPath(), {}, (ledger) => {
const sessions = recentSessions(ledger.sessions, now);
+ const session = sessions[sessionId] ?? {};
+ const reuses = [...(session.reuses ?? []), ...reusesOf(turn)].slice(-MAX_LISTED_REUSES);
return {
- total: added(ledger.total, tokens),
- sessions: { ...sessions, [sessionId]: { ...added(sessions[sessionId], tokens), at: now } },
+ total: added(ledger.total, turn.saving),
+ sessions: { ...sessions, [sessionId]: { ...added(session, turn.saving), reuses, at: now } },
};
});
}
-/** The savings counted for one chat and for all chats so far. */
+/** The savings counted for one chat, with its reuses, and for all chats so far. */
export function savingsOf(sessionId) {
const ledger = readState(ledgerPath(), {});
- const { tokens = 0, turns = 0 } = ledger.sessions?.[sessionId] ?? {};
- return { session: { tokens, turns }, total: { ...NO_SAVINGS, ...ledger.total } };
+ const { tokens = 0, turns = 0, reuses = [] } = ledger.sessions?.[sessionId] ?? {};
+ return { session: { tokens, turns }, reuses, total: { ...NO_SAVINGS, ...ledger.total } };
}
diff --git a/lib/status-report.mjs b/lib/status-report.mjs
index a4be883..0a386e6 100644
--- a/lib/status-report.mjs
+++ b/lib/status-report.mjs
@@ -82,10 +82,20 @@ function savedPhrase({ tokens, turns }) {
return `~${compactTokens(tokens)} tokens (≈$${usdOf(tokens).toFixed(2)}) over ${turns} reused turn${turns === 1 ? '' : 's'}`;
}
+const TITLE_MAX_CHARS = 60;
+const ID_SHOWN_CHARS = 14;
+
+function reuseLine({ turn, title, assetId, check, saved }) {
+ const name = String(title ?? assetId).slice(0, TITLE_MAX_CHARS);
+ const savedPart = saved === null ? 'no saving counted' : `~${compactTokens(saved)} saved`;
+ return ` turn ${turn} · "${name}" (${String(assetId).slice(0, ID_SHOWN_CHARS)}…) · last check ${check} · ${savedPart}`;
+}
+
// A reused turn counts only once a passing check vouched for it, and never for more than it spent itself.
-function savingsLines({ session, total }) {
+function savingsLines({ session, reuses, total }) {
return [
`SAVED by reusing EvoMap strategies — this chat: ${savedPhrase(session)} · all chats: ${savedPhrase(total)}`,
+ ...reuses.map(reuseLine),
' (estimated like the EvoMap ledger; counted only for reused turns whose last check passed)',
];
}
diff --git a/lib/turn-end.mjs b/lib/turn-end.mjs
index f22444b..ca606ca 100644
--- a/lib/turn-end.mjs
+++ b/lib/turn-end.mjs
@@ -32,17 +32,18 @@ function rememberTurnCandidate({ sessionId, session, reasonKind, record, model,
/**
* Every injected strategy is reported when its turn ends, with the turn's own evidence; the turn itself becomes
* the session's distillation candidate when it finished verified. Returns the reuses the next prompt may label,
- * whether anything was queued, and the turn's record. `ownerPid` is only asked when a candidate is written.
+ * the assets the turn reused, its number, whether anything was queued, and the turn's record. `ownerPid` is only asked when a candidate is written.
*/
function closeTurn({ sessionId, session, reasonKind, model, projectDir, ownerPid }) {
const record = takeTurnRecord(sessionId, session.turnId);
- if (!outcomeOfReason(reasonKind)) return { reuses: [], queued: false, record };
- const reports = reportsOf(takeUnreported(sessionId, session.turn), { sessionId, turn: session.turn, reasonKind, record });
+ if (!outcomeOfReason(reasonKind)) return { reuses: [], reused: [], turn: session.turn, queued: false, record };
+ const taken = takeUnreported(sessionId, session.turn);
+ const reports = reportsOf(taken, { sessionId, turn: session.turn, reasonKind, record });
const reuses = reports.map(reportedReuseOf);
queueReports(reports.map((report) => ({ report, sessionId })));
rememberForRetention({ sessionId, projectDir, reuses, editedBefore: record.editedBefore });
rememberTurnCandidate({ sessionId, session, reasonKind, record, model, ownerPid });
- return { reuses, queued: reports.length > 0, record };
+ return { reuses, reused: taken.map(({ assetId, title }) => ({ assetId, title })), turn: session.turn, queued: reports.length > 0, record };
}
/**
diff --git a/test/hooks.test.mjs b/test/hooks.test.mjs
index 34b03dc..898b5df 100644
--- a/test/hooks.test.mjs
+++ b/test/hooks.test.mjs
@@ -534,6 +534,7 @@ test('/evolver-status leads with the tokens reused strategies saved, in this cha
await lruTurn(s);
const status = (await s.prompt('/evolver-status')).user_message.split('\n');
assert.equal(status[1], 'SAVED by reusing EvoMap strategies — this chat: ~33k tokens (≈$0.30) over 1 reused turn · all chats: ~33k tokens (≈$0.30) over 1 reused turn');
+ assert.equal(status[2], ` turn 1 · "LRU cache in Node.js" (${ASSET_ID.slice(0, 14)}…) · last check passed · ~33k saved`);
const other = session('s-other');
assert.match((await other.prompt('/evolver-status')).user_message, /this chat: nothing yet · all chats: ~33k tokens/);
diff --git a/test/prime.test.mjs b/test/prime.test.mjs
index 880cc7e..db17682 100644
--- a/test/prime.test.mjs
+++ b/test/prime.test.mjs
@@ -20,9 +20,9 @@ test('one strategy is injected, the best-scoring one with enough steps, ending w
});
test('a short prompt, an already injected asset or a Proxy error inject nothing', async () => {
- assert.deepEqual(await hubGene(proxyWith([{ asset_id: 'a', strategy: steps(4) }]), 'fix it'), { ids: [], text: '' });
- assert.deepEqual(await hubGene(proxyWith([{ asset_id: 'a', strategy: steps(4) }]), 'add a retry to the uploader', { listedIds: new Set(['a']) }), { ids: [], text: '' });
- assert.deepEqual(await hubGene(async () => ({ ok: false, error: 'down' }), 'add a retry to the uploader'), { ids: [], text: '' });
+ assert.deepEqual(await hubGene(proxyWith([{ asset_id: 'a', strategy: steps(4) }]), 'fix it'), { ids: [], text: '', title: '' });
+ assert.deepEqual(await hubGene(proxyWith([{ asset_id: 'a', strategy: steps(4) }]), 'add a retry to the uploader', { listedIds: new Set(['a']) }), { ids: [], text: '', title: '' });
+ assert.deepEqual(await hubGene(async () => ({ ok: false, error: 'down' }), 'add a retry to the uploader'), { ids: [], text: '', title: '' });
});
test('the prompt loses keys and tokens before recall sends it', () => {
diff --git a/test/savings-ledger.test.mjs b/test/savings-ledger.test.mjs
index 098e820..aed74c5 100644
--- a/test/savings-ledger.test.mjs
+++ b/test/savings-ledger.test.mjs
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
-import { freshTokensOf, recordSaving, savingsOf, turnSavingOf } from '../lib/savings-ledger.mjs';
+import { freshTokensOf, recordReuseTurn, savingsOf, turnSavingOf } from '../lib/savings-ledger.mjs';
import { referenceReuseSavings } from '../lib/savings.mjs';
import { freshHome } from './helpers/sandbox.mjs';
@@ -23,13 +23,34 @@ test('a saving needs a reuse, a passing last check and known tokens, and never e
assert.equal(turnSavingOf({ reuses, record: passed, freshTokens: null }), null);
});
-test('savings add up per chat and in total; a chat unseen for the outbox TTL leaves its chat entry but not the total', (t) => {
+const PASSED = { command: 'npm test', failed: false };
+const reusedTurn = (turn, saving, lastCheck = PASSED) => ({ turn, reused: [{ assetId: `sha256:${turn}`, title: `Strategy ${turn}` }], lastCheck, saving });
+
+test('savings add up per chat and in total; a chat unseen for the state TTL leaves its chat entry but not the total', (t) => {
t.after(freshHome());
const day = 24 * 60 * 60 * 1000;
- recordSaving('old', 10_000, 0);
- recordSaving('new', 2_000, 4 * day);
- recordSaving('new', 3_000, 4 * day);
- assert.deepEqual(savingsOf('new'), { session: { tokens: 5_000, turns: 2 }, total: { tokens: 15_000, turns: 3 } });
+ recordReuseTurn('old', reusedTurn(1, 10_000), 0);
+ recordReuseTurn('new', reusedTurn(1, 2_000), 4 * day);
+ recordReuseTurn('new', reusedTurn(2, 3_000), 4 * day);
+ const { session, total, reuses } = savingsOf('new');
+ assert.deepEqual({ session, total }, { session: { tokens: 5_000, turns: 2 }, total: { tokens: 15_000, turns: 3 } });
+ assert.deepEqual(reuses.map((reuse) => reuse.turn), [1, 2]);
assert.deepEqual(savingsOf('old').session, { tokens: 0, turns: 0 });
- assert.deepEqual(savingsOf('never'), { session: { tokens: 0, turns: 0 }, total: { tokens: 15_000, turns: 3 } });
+ assert.deepEqual(savingsOf('never'), { session: { tokens: 0, turns: 0 }, reuses: [], total: { tokens: 15_000, turns: 3 } });
+});
+
+test('every reuse is listed with its asset and last check, a turn that saved nothing included; only the latest twenty are kept', (t) => {
+ t.after(freshHome());
+ recordReuseTurn('s', reusedTurn(1, null, { command: 'npm test', failed: true }));
+ recordReuseTurn('s', reusedTurn(2, null, null));
+ recordReuseTurn('s', { turn: 3, reused: [], lastCheck: PASSED, saving: 99 });
+ const { session, reuses } = savingsOf('s');
+ assert.deepEqual(session, { tokens: 0, turns: 0 }, 'a turn without a saving adds no saved turn');
+ assert.deepEqual(reuses, [
+ { turn: 1, assetId: 'sha256:1', title: 'Strategy 1', check: 'failed', saved: null },
+ { turn: 2, assetId: 'sha256:2', title: 'Strategy 2', check: 'none', saved: null },
+ ]);
+ for (let turn = 4; turn < 30; turn += 1) recordReuseTurn('s', reusedTurn(turn, 1));
+ assert.equal(savingsOf('s').reuses.length, 20);
+ assert.equal(savingsOf('s').reuses[0].turn, 10);
});
From 52d04ccac657001fe21e1728d576146871390307 Mon Sep 17 00:00:00 2001
From: forrestlinfeng
Date: Mon, 26 Oct 2026 20:00:53 +0800
Subject: [PATCH 7/7] fix: address Sentinel's advisories on the Cursor
hook-only plugin
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- sessionEnd ends only the turn it saw settle: a prompt that opened a new
turn in the same chat meanwhile keeps its session, injections and
candidate (endSession returns false and nothing is cleaned up).
- Tool, edit and response hooks record only under their own generation_id;
one without it no longer lands in the current turn.
- A distillation claim counts only once it is on disk, and candidates are
told apart by turn as well as timestamp.
- The background worker runs engine setup beside report delivery and
distillation, so a slow install no longer holds them back.
- Stripe-style keys (sk_live_…, rk_test_…) are redacted.
- The global Evolver core is also looked for under %APPDATA%\npm on Windows.
Co-Authored-By: Claude Opus 5.5
---
hooks/session-end.mjs | 7 ++++---
lib/local-ledger.mjs | 8 +++++---
lib/redact.mjs | 1 +
lib/session-distill.mjs | 10 +++++++---
lib/session-state.mjs | 20 +++++++++++++++-----
scripts/background-worker.mjs | 19 +++++++++----------
test/hooks.test.mjs | 25 +++++++++++++++++++++++++
test/local-ledger.test.mjs | 15 +++++++++++++++
test/redact.test.mjs | 7 +++++++
test/session-distill.test.mjs | 9 +++++++++
test/session-state.test.mjs | 23 +++++++++++++++++++++++
11 files changed, 120 insertions(+), 24 deletions(-)
create mode 100644 test/local-ledger.test.mjs
create mode 100644 test/session-state.test.mjs
diff --git a/hooks/session-end.mjs b/hooks/session-end.mjs
index 4860627..cbe0fa9 100644
--- a/hooks/session-end.mjs
+++ b/hooks/session-end.mjs
@@ -13,15 +13,16 @@ const STOP_WAIT_MS = 2_000;
// Cursor waits for sessionEnd only while a window closes, and kills it at its timeout, so it only settles state
// on disk; the closed candidate is distilled by the detached worker it starts, or claimed by the next session
// start if that worker fails. A stop already settling its turn is let finish first, so its candidate is the one
-// closed here; a turn still running is cut off by the close and ends aborted.
+// closed here; a turn still running is cut off by the close and ends aborted. A prompt that opened a new turn in the
+// meantime means the chat lives on, and nothing of it is cleaned up.
runHook(async (input) => {
const sessionId = sessionIdOf(input);
if (!sessionId) return {};
const session = await settledSession(sessionId, STOP_WAIT_MS);
- if (!session.closed) endOpenTurn({ sessionId, reasonKind: 'aborted', projectDir: projectDirOf(input) });
+ if (!session.closed) endOpenTurn({ sessionId, turnId: session.turnId, reasonKind: 'aborted', projectDir: projectDirOf(input) });
+ if (!endSession(sessionId, session.turn)) return {};
queueReports(abortedReports(takeUnreported(sessionId, Infinity)));
closeCandidate(sessionId);
- endSession(sessionId);
forgetSession(sessionId);
startInBackground([DRAIN_TASK, DISTILL_TASK]);
return {};
diff --git a/lib/local-ledger.mjs b/lib/local-ledger.mjs
index cd5298a..4dbcb11 100644
--- a/lib/local-ledger.mjs
+++ b/lib/local-ledger.mjs
@@ -10,9 +10,11 @@ const MAX_TITLE_CHARS = 80;
const CORE_PACKAGE = '@evomap/evolver-core';
const ENGINE_PACKAGE = path.join('node_modules', '@evomap', 'evolver', 'package.json');
-function globalPackageRoots() {
- const nodeDir = path.dirname(process.execPath);
- return process.platform === 'win32' ? [path.join(nodeDir, ENGINE_PACKAGE)] : [path.join(nodeDir, '..', 'lib', ENGINE_PACKAGE)];
+// npm's global root sits beside node on POSIX and Windows installers alike, but defaults to %APPDATA%\npm on Windows.
+export function globalPackageRoots({ platform = process.platform, execPath = process.execPath, env = process.env } = {}) {
+ const nodeDir = path.dirname(execPath);
+ if (platform !== 'win32') return [path.join(nodeDir, '..', 'lib', ENGINE_PACKAGE)];
+ return [...(env.APPDATA ? [path.join(env.APPDATA, 'npm', ENGINE_PACKAGE)] : []), path.join(nodeDir, ENGINE_PACKAGE)];
}
// This plugin ships no node_modules, so the core is resolved through the Evolver it set up, or the global one.
diff --git a/lib/redact.mjs b/lib/redact.mjs
index 3e7915a..048351a 100644
--- a/lib/redact.mjs
+++ b/lib/redact.mjs
@@ -1,6 +1,7 @@
const SECRET_PATTERNS = [
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g,
/\b(?:sk|pk|rk)-(?:ant-|proj-|live-|test-)?[A-Za-z0-9_-]{16,}/g,
+ /\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g,
/\bgh[pousr]_[A-Za-z0-9]{20,}/g,
/\bgithub_pat_[A-Za-z0-9_]{20,}/g,
/\bglpat-[A-Za-z0-9_-]{20,}/g,
diff --git a/lib/session-distill.mjs b/lib/session-distill.mjs
index 055c58e..5178129 100644
--- a/lib/session-distill.mjs
+++ b/lib/session-distill.mjs
@@ -73,7 +73,7 @@ export function dropCandidate(sessionId) {
/** Whether this is still the session's candidate on disk: not dropped by a correction, nor replaced by a newer one. */
export function isCurrentCandidate(sessionId, candidate) {
- return readState(pendingPath(), {})[sessionId]?.at === candidate.at;
+ return isSameCandidate(readState(pendingPath(), {})[sessionId], candidate);
}
/** A session that ended keeps its candidate due, so a distillation that failed at its end is tried again. */
@@ -111,6 +111,9 @@ export function dueCandidates({ sessionId }) {
return candidate ? [{ sessionId, candidate }] : [];
}
+const isSameCandidate = (left, right) => left?.at === right?.at && left?.turn === right?.turn;
+
+// The state write can fail or race another process that got no lock, so a claim counts only once it is on disk.
function claimWhere(isDue, pid) {
const claimed = [];
updateState(pendingPath(), {}, (pending) => {
@@ -122,7 +125,8 @@ function claimWhere(isDue, pid) {
}
return claimed.length > 0 ? next : undefined;
});
- return claimed;
+ const onDisk = readState(pendingPath(), {});
+ return claimed.filter(({ sessionId, candidate }) => onDisk[sessionId]?.claimedBy === pid && isSameCandidate(onDisk[sessionId], candidate));
}
/**
@@ -146,7 +150,7 @@ export function claimSessionCandidate(sessionId, { isGone = ownerIsGone, pid = p
export function settleCandidate(sessionId, candidate, { sent }) {
updateState(pendingPath(), {}, (pending) => {
const current = pending[sessionId];
- if (!current || current.at !== candidate.at) return undefined;
+ if (!isSameCandidate(current, candidate)) return undefined;
const attempts = (current.attempts ?? 0) + 1;
if (sent || attempts >= MAX_ATTEMPTS) {
const { [sessionId]: settled, ...rest } = pending;
diff --git a/lib/session-state.mjs b/lib/session-state.mjs
index cdc88b3..8c72418 100644
--- a/lib/session-state.mjs
+++ b/lib/session-state.mjs
@@ -20,10 +20,13 @@ function isOpenTurn(session, turnId) {
return !session.closed && (!turnId || !session.turnId || turnId === session.turnId);
}
-/** The open turn a tool or response hook of `turnId` belongs to, or null when it should record nothing. */
+/**
+ * The open turn a tool or response hook of `turnId` belongs to, or undefined when it should record nothing. Unlike
+ * closing, recording needs the exact generation: a late hook of an earlier turn must not land in the current one.
+ */
export function recordingTurnOf(sessionId, turnId) {
const session = readSession(sessionId);
- return isOpenTurn(session, turnId) ? session.turnId : undefined;
+ return !session.closed && (turnId ?? null) === session.turnId ? session.turnId : undefined;
}
export function updateSession(sessionId, change) {
@@ -71,10 +74,17 @@ export async function settledSession(sessionId, timeoutMs) {
/**
* Forgets what the ended session's turns left, but keeps its turn counter: a chat reopened after
* sessionEnd keeps its conversation_id, and a repeated `cursor::` would be taken by the Hub as a duplicate report.
- * The file is pruned with the other session files once it is older than the outbox TTL.
+ * The file is pruned with the other session files once it is older than the state TTL. Only the turn the caller saw
+ * end is ended: a prompt that opened another turn meanwhile keeps the session, and false is returned.
*/
-export function endSession(sessionId) {
- updateSession(sessionId, (session) => ({ ...EMPTY_SESSION, turn: session.turn }));
+export function endSession(sessionId, endedTurn) {
+ let ended = false;
+ updateSession(sessionId, (session) => {
+ if (!session.closed || session.turn !== endedTurn) return undefined;
+ ended = true;
+ return { ...EMPTY_SESSION, turn: session.turn };
+ });
+ return ended;
}
export function pruneOldSessions(now = Date.now()) {
diff --git a/scripts/background-worker.mjs b/scripts/background-worker.mjs
index 534d13b..ee45cd5 100644
--- a/scripts/background-worker.mjs
+++ b/scripts/background-worker.mjs
@@ -20,15 +20,14 @@ async function ranCleanly(work) {
}
const tasks = new Set(process.argv.slice(2));
-const results = [await ranCleanly(() => pruneOldSessions()), await ranCleanly(() => dropStaleCandidates())];
-if (tasks.has(ENGINE_TASK)) results.push(await ranCleanly(() => setUpEngine(engineRuntime())));
-if (tasks.has(DRAIN_TASK)) {
- results.push(await ranCleanly(() => drainOutbox({ proxyFetch: createProxyClient(), recordLocally: recordReuseLocally })));
-}
-if (tasks.has(DISTILL_TASK)) {
- results.push(await ranCleanly(() => distillLeftovers({
+const housekeeping = [await ranCleanly(() => pruneOldSessions()), await ranCleanly(() => dropStaleCandidates())];
+// Installing Evolver can take minutes, so it runs beside report delivery and distillation rather than before them.
+const jobs = await Promise.all([
+ tasks.has(ENGINE_TASK) && ranCleanly(() => setUpEngine(engineRuntime())),
+ tasks.has(DRAIN_TASK) && ranCleanly(() => drainOutbox({ proxyFetch: createProxyClient(), recordLocally: recordReuseLocally })),
+ tasks.has(DISTILL_TASK) && ranCleanly(() => distillLeftovers({
proxyFetch: createProxyClient({ timeoutMs: DISTILL_TIMEOUT_MS }),
runModel: cursorModelRunner(),
- })));
-}
-process.exit(results.every(Boolean) ? 0 : 1);
+ })),
+].filter(Boolean));
+process.exit([...housekeeping, ...jobs].every(Boolean) ? 0 : 1);
diff --git a/test/hooks.test.mjs b/test/hooks.test.mjs
index 898b5df..df5ac98 100644
--- a/test/hooks.test.mjs
+++ b/test/hooks.test.mjs
@@ -548,3 +548,28 @@ test('a reused turn whose last check failed, or that ran none, saves nothing', a
await s.finish('Still failing.');
assert.match((await s.prompt('/evolver-status')).user_message, /this chat: nothing yet · all chats: nothing yet/);
});
+
+test('a late sessionEnd leaves alone a turn a new prompt opened in the same chat', async () => {
+ await startSandbox();
+ const s = session('s-reopen');
+ await s.prompt(LRU_PROMPT);
+ await s.finish('done');
+ await waitFor(() => reuseReports());
+ const ending = s.end();
+ await s.prompt('用 Node 再写一个 LRU cache 的测试,覆盖淘汰顺序');
+ await ending;
+ await s.shell('node --test', PASSED_RUN);
+ await s.finish('tests pass');
+ const second = await waitFor(() => reuseReports().find((report) => report.task_id === 'cursor:s-reopen:2'));
+ assert.equal(second.evidence.tier, 'L1', 'the new turn kept its injection and its record');
+});
+
+test('a hook without generation_id records nothing into a turn that has one', async () => {
+ await startSandbox();
+ const s = session('s-nogen');
+ await s.prompt(LRU_PROMPT);
+ await runHook('post-tool-use', { conversation_id: 's-nogen', hook_event_name: 'postToolUse', tool_name: 'Shell', tool_input: { command: 'npm test' }, tool_output: JSON.stringify({ output: PASSED_RUN, exitCode: 0 }) }, sandbox.env);
+ await s.finish('done');
+ const [report] = await waitFor(() => reuseReports());
+ assert.equal(report.evidence.check, undefined);
+});
diff --git a/test/local-ledger.test.mjs b/test/local-ledger.test.mjs
new file mode 100644
index 0000000..116cce5
--- /dev/null
+++ b/test/local-ledger.test.mjs
@@ -0,0 +1,15 @@
+import assert from 'node:assert/strict';
+import { join } from 'node:path';
+import { test } from 'node:test';
+
+import { globalPackageRoots } from '../lib/local-ledger.mjs';
+
+const PACKAGE = join('node_modules', '@evomap', 'evolver', 'package.json');
+
+test("the global Evolver is looked for in npm's global root: beside node on POSIX, %APPDATA%\\npm first on Windows", () => {
+ assert.deepEqual(globalPackageRoots({ platform: 'darwin', execPath: '/opt/homebrew/bin/node', env: {} }), [join('/opt/homebrew/bin', '..', 'lib', PACKAGE)]);
+ assert.deepEqual(globalPackageRoots({ platform: 'win32', execPath: '/nodejs/node.exe', env: { APPDATA: '/Users/me/AppData/Roaming' } }), [
+ join('/Users/me/AppData/Roaming', 'npm', PACKAGE),
+ join('/nodejs', PACKAGE),
+ ]);
+});
diff --git a/test/redact.test.mjs b/test/redact.test.mjs
index 149dea9..d48889c 100644
--- a/test/redact.test.mjs
+++ b/test/redact.test.mjs
@@ -28,3 +28,10 @@ test('code identifiers that merely end in a secret word are left alone', () => {
test('a secret behind an unclosed quote is still redacted', () => {
for (const text of ['API_KEY="abcdef123456', "TOKEN='abcdef123456"]) assert.doesNotMatch(withoutSecrets(text), /abcdef123456/, text);
});
+
+// Assembled at run time, so no literal that secret scanners take for a live key is committed.
+const stripeStyle = (prefix, mode) => [prefix, mode, 'x'.repeat(24)].join('_');
+
+test('Stripe keys written with underscores are redacted', () => {
+ assert.equal(withoutSecrets(`key ${stripeStyle('sk', 'live')} and ${stripeStyle('rk', 'test')}`), 'key [redacted] and [redacted]');
+});
diff --git a/test/session-distill.test.mjs b/test/session-distill.test.mjs
index 3340acc..7f2e9b5 100644
--- a/test/session-distill.test.mjs
+++ b/test/session-distill.test.mjs
@@ -188,3 +188,12 @@ test('a candidate older than three days is dropped whoever owns it; a fresher on
assert.deepEqual(dueCandidates({ sessionId: 'stuck' }), []);
assert.equal(dueCandidates({ sessionId: 'fresh' }).length, 1);
});
+
+test('a claim that did not reach the disk is not handed out', (t) => {
+ t.after(freshHome());
+ rememberCandidate('s', { pid: null, closed: true, at: Date.now(), turn: 1 });
+ const lost = claimLeftoverCandidates({ isGone: () => false, pid: 7 });
+ assert.equal(lost.length, 1);
+ rememberCandidate('s', { pid: null, closed: true, at: Date.now() + 1, turn: 2 });
+ assert.equal(isCurrentCandidate('s', lost[0].candidate), false, 'a newer candidate is not the claimed one');
+});
diff --git a/test/session-state.test.mjs b/test/session-state.test.mjs
new file mode 100644
index 0000000..cd1dd71
--- /dev/null
+++ b/test/session-state.test.mjs
@@ -0,0 +1,23 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { endSession, readSession, recordingTurnOf, updateSession } from '../lib/session-state.mjs';
+import { freshHome } from './helpers/sandbox.mjs';
+
+test('sessionEnd ends only the turn it saw: a turn opened since keeps the session', (t) => {
+ t.after(freshHome());
+ updateSession('s', (session) => ({ ...session, turn: 2, turnId: 'g2', closed: false }));
+ assert.equal(endSession('s', 1), false);
+ assert.equal(readSession('s').turnId, 'g2');
+ updateSession('s', (session) => ({ ...session, closed: true }));
+ assert.equal(endSession('s', 2), true);
+ assert.deepEqual({ turn: readSession('s').turn, turnId: readSession('s').turnId }, { turn: 2, turnId: null });
+});
+
+test('a hook records into the open turn only with that turn\'s generation', (t) => {
+ t.after(freshHome());
+ updateSession('s', (session) => ({ ...session, turn: 1, turnId: 'g1', closed: false }));
+ assert.equal(recordingTurnOf('s', 'g1'), 'g1');
+ assert.equal(recordingTurnOf('s', null), undefined);
+ assert.equal(recordingTurnOf('s', 'g0'), undefined);
+});