mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
f225e30368
* fix(hooks): scope session-start memory recall to the current workspace The session-start hook injected the last 5 memory-graph outcomes with no workspace filter, while the session-end writer already tags every entry with workspace_id (forge-resistant) and cwd (backward-compat). On npm-global installs every project shares the user-level fallback graph (~/.evolver/memory/evolution/memory_graph.jsonl), so project A's session start would surface project B's outcomes — the cross-project disclosure / prompt-injection surface Bugbot flagged on the writer side (PR #105 round-2), which the reader never enforced. Add resolveWorkspaceId() to _runtimePaths.js mirroring the writer's resolution (EVOLVER_WORKSPACE_ID, then paths.getWorkspaceId() from the resolved evolver root). session-start now scopes entries to the current workspace BEFORE taking the most-recent window — filtering after a tail-N read would let other projects crowd this workspace out of the window entirely. Untagged legacy/Hub entries and the can't-resolve-id case both fall through to 'show it', so there is no regression vs. the old unscoped behavior; only cross-project leakage is removed. Tests: test/sessionStartScope.test.js covers isolation, the empty case, legacy pass-through, cwd fallback, and the belongsToWorkspace predicate branches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(hooks): log a breadcrumb when session-end records nothing session-end derives the outcome (status/score/signals/summary) entirely from the git diff. In a non-git workspace, or a repo with no changes this session, there is no signal source, so the hook correctly records nothing rather than fabricating an empty outcome that would pollute the memory graph. But the no-changes branch was fully silent, so a user could not tell 'evolver ran and had nothing to record' from 'evolver never fired'. Emit a one-line breadcrumb to evolution.log on that branch, distinguishing 'not a git workspace' from 'no changes detected this session' (a single cheap rev-parse settles which, since the diff commands can't). No memory -graph entry is written. Factor the existing inline log-append into a shared appendEvolutionLog() helper used by both the recorded-outcome and skip paths. Tests: two regressions in sessionEndHook.test.js asserting the breadcrumb is logged and no graph entry is written, for both the non-git and clean-repo cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(hooks): share one workspace-id resolver between reader and writer Bugbot (PR #555) flagged that the new _runtimePaths.resolveWorkspaceId duplicated evolver-session-end.js#resolveWorkspaceIdForWriter. The two copies were equivalent now, but if either drifted the reader and writer would resolve different ids — and since the reader filters memory-graph entries by exact workspace_id match, a drift would silently match nothing and disable workspace scoping entirely. Delete resolveWorkspaceIdForWriter and have the writer call the shared resolveWorkspaceId() (no-arg form is behavior-identical: EVOLVER_WORKSPACE_ID then paths.getWorkspaceId() from findEvolverRoot()). Single source of truth; drift is now impossible by construction. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(hooks): tag entry.cwd with resolveProjectDir(), not process.cwd() Bugbot (PR #555 round-2) flagged a reader/writer cwd mismatch: the session-start reader derives its cwd fallback from resolveProjectDir() (CURSOR_PROJECT_DIR under Cursor), but the writer still stamped cwd: process.cwd() — the plugin install dir under Cursor. When an entry has no workspace_id and belongsToWorkspace() falls to the cwd compare, the writer's plugin-dir cwd could never equal the reader's project-dir currentDir, silently hiding every cwd-only entry. The writer also already used resolveProjectDir() for git-diff collection (#554), making the raw process.cwd() tag internally inconsistent. Stamp cwd with resolveProjectDir() so the diff source, the cwd tag, and the reader's fallback all agree. collect.js uses cwd only as a legacy fallback (disabled once a workspace_id secret exists), and the tag is still a directory path, so its scoping contract is unchanged. Test: regression asserting entry.cwd == CURSOR_PROJECT_DIR (not the hook process cwd) under a simulated Cursor host. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf(hooks): bound session-start memory parse instead of parsing the whole file Bugbot (PR #555 round-3) flagged that the workspace-scoping fix replaced readLastN (which read the whole file but parsed only the last 5 lines) with readAllEntries, which JSON-parses every line. The memory graph can reach ~100 MB before rotation, so parsing hundreds of thousands of entries on every session start is real CPU/memory cost. Replace readAllEntries with readRecentWorkspaceEntries: it still reads the file (cheap, as readLastN did) but parses lines lazily from the newest end, keeps only workspace matches, and stops once it has N. Parse count is bounded by where this workspace's N-th-most-recent entry sits, not by total file size — while preserving scope-before-trim correctness (other projects can't crowd this workspace out of the window). Test: a 200-other-entry graph with this workspace's entries behind them, asserting the recent own entry still surfaces with no cross-project leak. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: autogame-17 <autogame-17@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
295 lines
12 KiB
JavaScript
295 lines
12 KiB
JavaScript
const { describe, it } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const { execFileSync, execSync } = require('child_process');
|
|
|
|
const repoRoot = path.resolve(__dirname, '..');
|
|
const scriptPath = path.join(repoRoot, 'src', 'adapters', 'scripts', 'evolver-session-end.js');
|
|
|
|
function makeTmpDir() {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'evolver-session-end-test-'));
|
|
}
|
|
|
|
function cleanup(dir) {
|
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
}
|
|
|
|
// Spin up a minimal git repo with a staged diff so the hook detects "changes"
|
|
// and proceeds past the `hasChanges` early-return.
|
|
function initRepoWithDiff(dir) {
|
|
execSync('git init -q', { cwd: dir });
|
|
execSync('git config user.email test@example.com', { cwd: dir });
|
|
execSync('git config user.name test', { cwd: dir });
|
|
fs.writeFileSync(path.join(dir, 'a.txt'), 'hello\n');
|
|
execSync('git add a.txt', { cwd: dir });
|
|
execSync('git commit -q -m initial', { cwd: dir });
|
|
// Modify the file so `git diff --stat` is non-empty.
|
|
fs.writeFileSync(path.join(dir, 'a.txt'), 'hello\nworld\n');
|
|
}
|
|
|
|
function baseEnv(extra) {
|
|
return {
|
|
PATH: process.env.PATH,
|
|
HOME: extra.HOME,
|
|
// Pin EVOLVER_ROOT so _runtimePaths.findEvolverRoot picks our repo
|
|
// package.json deterministically even from a tmp cwd.
|
|
EVOLVER_ROOT: repoRoot,
|
|
// Force Hub off — we only care about the local-memory path here.
|
|
EVOMAP_HUB_URL: '',
|
|
A2A_HUB_URL: '',
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function runHook(env, cwd) {
|
|
const out = execFileSync('node', [scriptPath], {
|
|
cwd,
|
|
env,
|
|
input: '{}',
|
|
encoding: 'utf8',
|
|
timeout: 15000,
|
|
});
|
|
try { return JSON.parse(out); } catch { return null; }
|
|
}
|
|
|
|
describe('evolver-session-end Cursor compatibility', () => {
|
|
it('emits systemMessage on non-Cursor hosts', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
initRepoWithDiff(tmp);
|
|
const logDir = path.join(tmp, 'evolver-logs');
|
|
const env = baseEnv({
|
|
HOME: tmp,
|
|
EVOLVER_HOOK_LOG_DIR: logDir,
|
|
// explicitly clear any Cursor markers inherited from parent shell
|
|
TERM_PROGRAM: 'xterm',
|
|
EVOLVER_HOOK_HOST: '',
|
|
});
|
|
delete env.CURSOR_TRACE_ID;
|
|
delete env.CURSOR_SESSION_ID;
|
|
|
|
const result = runHook(env, tmp);
|
|
assert.ok(result && typeof result.systemMessage === 'string',
|
|
`expected systemMessage on non-Cursor, got ${JSON.stringify(result)}`);
|
|
assert.match(result.systemMessage, /\[Evolution\]/);
|
|
assert.equal(result.followup_message, undefined,
|
|
'must not emit followup_message — that field re-injects the receipt as a user prompt');
|
|
assert.ok(fs.existsSync(path.join(logDir, 'evolution.log')),
|
|
'evolution.log must be appended even on non-Cursor hosts');
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('suppresses systemMessage when TERM_PROGRAM=cursor', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
initRepoWithDiff(tmp);
|
|
const logDir = path.join(tmp, 'evolver-logs');
|
|
const env = baseEnv({
|
|
HOME: tmp,
|
|
EVOLVER_HOOK_LOG_DIR: logDir,
|
|
TERM_PROGRAM: 'cursor',
|
|
});
|
|
|
|
const result = runHook(env, tmp);
|
|
assert.deepEqual(result, {},
|
|
`expected empty object on Cursor, got ${JSON.stringify(result)}`);
|
|
assert.ok(fs.existsSync(path.join(logDir, 'evolution.log')),
|
|
'evolution.log must still be appended so the user can find the receipt');
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('suppresses systemMessage when CURSOR_TRACE_ID is set', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
initRepoWithDiff(tmp);
|
|
const env = baseEnv({
|
|
HOME: tmp,
|
|
EVOLVER_HOOK_LOG_DIR: path.join(tmp, 'logs'),
|
|
TERM_PROGRAM: 'xterm',
|
|
CURSOR_TRACE_ID: 'abc-123',
|
|
});
|
|
const result = runHook(env, tmp);
|
|
assert.deepEqual(result, {});
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('respects EVOLVER_HOOK_VERBOSE=1 escape hatch on Cursor', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
initRepoWithDiff(tmp);
|
|
const env = baseEnv({
|
|
HOME: tmp,
|
|
EVOLVER_HOOK_LOG_DIR: path.join(tmp, 'logs'),
|
|
TERM_PROGRAM: 'cursor',
|
|
EVOLVER_HOOK_VERBOSE: '1',
|
|
});
|
|
const result = runHook(env, tmp);
|
|
assert.ok(result && typeof result.systemMessage === 'string',
|
|
`EVOLVER_HOOK_VERBOSE=1 must force systemMessage on, got ${JSON.stringify(result)}`);
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
|
|
it('respects manual EVOLVER_HOOK_HOST=cursor override', () => {
|
|
const tmp = makeTmpDir();
|
|
try {
|
|
initRepoWithDiff(tmp);
|
|
const env = baseEnv({
|
|
HOME: tmp,
|
|
EVOLVER_HOOK_LOG_DIR: path.join(tmp, 'logs'),
|
|
TERM_PROGRAM: 'xterm',
|
|
EVOLVER_HOOK_HOST: 'cursor',
|
|
});
|
|
const result = runHook(env, tmp);
|
|
assert.deepEqual(result, {});
|
|
} finally { cleanup(tmp); }
|
|
});
|
|
});
|
|
|
|
describe('evolver-session-end project-dir resolution', () => {
|
|
// Regression: Cursor runs hooks with cwd set to the plugin install dir, not
|
|
// the user's repo. The hook must read CURSOR_PROJECT_DIR / CLAUDE_PROJECT_DIR
|
|
// to find the repo and collect a real diff — otherwise it records nothing.
|
|
it('records the diff from CURSOR_PROJECT_DIR even when cwd is elsewhere', () => {
|
|
const repo = makeTmpDir(); // the user's actual project (has the diff)
|
|
const elsewhere = makeTmpDir(); // simulate Cursor's plugin-dir cwd (no repo)
|
|
const home = makeTmpDir();
|
|
try {
|
|
initRepoWithDiff(repo);
|
|
const logDir = path.join(home, 'logs');
|
|
const env = baseEnv({
|
|
HOME: home,
|
|
EVOLVER_HOOK_LOG_DIR: logDir,
|
|
TERM_PROGRAM: 'xterm', // non-Cursor → emits systemMessage so we can assert
|
|
EVOLVER_HOOK_HOST: '',
|
|
CURSOR_PROJECT_DIR: repo, // host points us at the real repo
|
|
});
|
|
delete env.CURSOR_TRACE_ID;
|
|
delete env.CURSOR_SESSION_ID;
|
|
|
|
const result = runHook(env, elsewhere); // cwd = wrong dir, like Cursor
|
|
assert.ok(result && typeof result.systemMessage === 'string',
|
|
`expected a recorded outcome via CURSOR_PROJECT_DIR, got ${JSON.stringify(result)}`);
|
|
assert.match(result.systemMessage, /file/, 'should report changed files from the repo');
|
|
} finally { cleanup(repo); cleanup(elsewhere); cleanup(home); }
|
|
});
|
|
|
|
it('CLAUDE_PROJECT_DIR alias also resolves the repo', () => {
|
|
const repo = makeTmpDir();
|
|
const elsewhere = makeTmpDir();
|
|
const home = makeTmpDir();
|
|
try {
|
|
initRepoWithDiff(repo);
|
|
const env = baseEnv({
|
|
HOME: home,
|
|
EVOLVER_HOOK_LOG_DIR: path.join(home, 'logs'),
|
|
TERM_PROGRAM: 'xterm',
|
|
EVOLVER_HOOK_HOST: '',
|
|
CLAUDE_PROJECT_DIR: repo,
|
|
});
|
|
delete env.CURSOR_TRACE_ID;
|
|
delete env.CURSOR_SESSION_ID;
|
|
|
|
const result = runHook(env, elsewhere);
|
|
assert.ok(result && typeof result.systemMessage === 'string',
|
|
`expected a recorded outcome via CLAUDE_PROJECT_DIR, got ${JSON.stringify(result)}`);
|
|
} finally { cleanup(repo); cleanup(elsewhere); cleanup(home); }
|
|
});
|
|
});
|
|
|
|
describe('evolver-session-end no-changes log breadcrumb', () => {
|
|
// A non-git workspace has no diff -> no signal source -> nothing is recorded
|
|
// (recording an empty outcome would pollute the memory graph). But the hook
|
|
// must not be fully silent: it logs a one-line skip notice so a user can tell
|
|
// "ran but had nothing to record" from "never fired".
|
|
it('logs a "not a git workspace" skip notice and records no outcome', () => {
|
|
const nongit = makeTmpDir(); // plain dir, no git init
|
|
const home = makeTmpDir();
|
|
try {
|
|
const logDir = path.join(home, 'logs');
|
|
const env = baseEnv({ HOME: home, EVOLVER_HOOK_LOG_DIR: logDir, TERM_PROGRAM: 'xterm' });
|
|
delete env.CURSOR_TRACE_ID;
|
|
delete env.CURSOR_SESSION_ID;
|
|
|
|
const result = runHook(env, nongit);
|
|
assert.deepEqual(result, {}, 'no outcome should be emitted in a non-git workspace');
|
|
|
|
const logFile = path.join(logDir, 'evolution.log');
|
|
assert.ok(fs.existsSync(logFile), 'a skip breadcrumb must be logged');
|
|
const log = fs.readFileSync(logFile, 'utf8');
|
|
assert.match(log, /nothing recorded \(not a git workspace\)/);
|
|
// And it must NOT have written a memory-graph entry.
|
|
const graph = path.join(home, '.evolver', 'memory', 'evolution', 'memory_graph.jsonl');
|
|
assert.ok(!fs.existsSync(graph) || fs.readFileSync(graph, 'utf8').trim() === '',
|
|
'no memory-graph entry should be written when there are no changes');
|
|
} finally { cleanup(nongit); cleanup(home); }
|
|
});
|
|
|
|
it('logs a "no changes detected" notice in a clean git repo', () => {
|
|
const repo = makeTmpDir();
|
|
const home = makeTmpDir();
|
|
try {
|
|
// git repo with a committed file but NO uncommitted change this session.
|
|
execSync('git init -q', { cwd: repo });
|
|
execSync('git config user.email test@example.com', { cwd: repo });
|
|
execSync('git config user.name test', { cwd: repo });
|
|
fs.writeFileSync(path.join(repo, 'a.txt'), 'hello\n');
|
|
execSync('git add a.txt', { cwd: repo });
|
|
execSync('git commit -q -m initial', { cwd: repo });
|
|
// No second commit and no edit -> diff HEAD~1 fails, working tree clean.
|
|
|
|
const logDir = path.join(home, 'logs');
|
|
const env = baseEnv({ HOME: home, EVOLVER_HOOK_LOG_DIR: logDir, TERM_PROGRAM: 'xterm' });
|
|
delete env.CURSOR_TRACE_ID;
|
|
delete env.CURSOR_SESSION_ID;
|
|
|
|
const result = runHook(env, repo);
|
|
assert.deepEqual(result, {});
|
|
const log = fs.readFileSync(path.join(logDir, 'evolution.log'), 'utf8');
|
|
assert.match(log, /nothing recorded \(no changes detected this session\)/);
|
|
} finally { cleanup(repo); cleanup(home); }
|
|
});
|
|
});
|
|
|
|
describe('evolver-session-end cwd tag consistency (reader/writer match)', () => {
|
|
// Regression (Bugbot PR #555 round-2): the writer must stamp the entry's
|
|
// `cwd` with resolveProjectDir() — the SAME resolver the session-start
|
|
// reader uses for its cwd fallback — not raw process.cwd(). Under Cursor the
|
|
// hook's process.cwd() is the plugin install dir, so a raw-cwd tag would
|
|
// never equal the reader's project-dir-derived currentDir, silently hiding
|
|
// every cwd-only entry. Force Hub off so the entry lands in local memory.
|
|
it('tags entry.cwd with CURSOR_PROJECT_DIR, not the hook process cwd', () => {
|
|
const repo = makeTmpDir(); // user's project, where the diff lives
|
|
const elsewhere = makeTmpDir(); // simulate Cursor's plugin-dir cwd
|
|
const home = makeTmpDir();
|
|
try {
|
|
initRepoWithDiff(repo);
|
|
const graph = path.join(home, '.evolver', 'memory', 'evolution', 'memory_graph.jsonl');
|
|
// recordToLocal appends to MEMORY_GRAPH_PATH but does not mkdir for an
|
|
// explicit path — create the parent the way a real install would.
|
|
fs.mkdirSync(path.dirname(graph), { recursive: true });
|
|
const env = baseEnv({
|
|
HOME: home,
|
|
EVOLVER_HOOK_LOG_DIR: path.join(home, 'logs'),
|
|
MEMORY_GRAPH_PATH: graph,
|
|
TERM_PROGRAM: 'cursor', // Cursor host
|
|
CURSOR_PROJECT_DIR: repo, // real project dir
|
|
// Hub off so recordToLocal runs and we can inspect the entry.
|
|
EVOMAP_API_KEY: '', A2A_NODE_SECRET: '', EVOMAP_NODE_ID: '', A2A_NODE_ID: '',
|
|
});
|
|
|
|
const result = runHook(env, elsewhere); // process.cwd() = plugin-ish dir
|
|
assert.deepEqual(result, {}, 'Cursor host suppresses systemMessage');
|
|
|
|
assert.ok(fs.existsSync(graph), 'a local memory entry should be written');
|
|
const last = fs.readFileSync(graph, 'utf8').trim().split('\n').filter(Boolean).pop();
|
|
const entry = JSON.parse(last);
|
|
assert.equal(entry.cwd, repo,
|
|
`entry.cwd must be the project dir (${repo}), got ${entry.cwd}`);
|
|
assert.notEqual(entry.cwd, elsewhere,
|
|
'entry.cwd must NOT be the hook process cwd (the plugin dir under Cursor)');
|
|
} finally { cleanup(repo); cleanup(elsewhere); cleanup(home); }
|
|
});
|
|
});
|