@evomap/evolver 1.87.3 → 1.88.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +848 -33
- package/package.json +1 -1
- package/scripts/build_binaries.js +11 -1
- package/src/adapters/hookAdapter.js +3 -1
- package/src/adapters/scripts/_runtimePaths.js +202 -1
- package/src/adapters/scripts/evolver-session-end.js +160 -98
- package/src/adapters/scripts/evolver-session-start.js +227 -43
- package/src/config.js +43 -8
- package/src/evolve/guards.js +1 -1
- package/src/evolve/pipeline/collect.js +1 -1
- package/src/evolve/pipeline/dispatch.js +1 -1
- package/src/evolve/pipeline/enrich.js +1 -1
- package/src/evolve/pipeline/hub.js +1 -1
- package/src/evolve/pipeline/select.js +1 -1
- package/src/evolve/pipeline/signals.js +1 -1
- package/src/evolve/utils.js +1 -1
- package/src/evolve.js +1 -1
- package/src/forceUpdate.js +42 -21
- package/src/gep/a2aProtocol.js +1 -1
- package/src/gep/assetStore.js +40 -0
- package/src/gep/candidateEval.js +1 -1
- package/src/gep/candidates.js +1 -1
- package/src/gep/contentHash.js +1 -1
- package/src/gep/crypto.js +1 -1
- package/src/gep/curriculum.js +1 -1
- package/src/gep/deviceId.js +1 -1
- package/src/gep/envFingerprint.js +1 -1
- package/src/gep/epigenetics.js +1 -1
- package/src/gep/explore.js +1 -1
- package/src/gep/featureFlags.js +4 -0
- package/src/gep/gitOps.js +7 -2
- package/src/gep/hash.js +1 -1
- package/src/gep/hubFetch.js +1 -1
- package/src/gep/hubReview.js +1 -1
- package/src/gep/hubSearch.js +1 -1
- package/src/gep/hubVerify.js +1 -1
- package/src/gep/idleScheduler.js +233 -6
- package/src/gep/learningSignals.js +1 -1
- package/src/gep/mailboxTransport.js +34 -0
- package/src/gep/memoryGraph.js +1 -1
- package/src/gep/memoryGraphAdapter.js +1 -1
- package/src/gep/mutation.js +1 -1
- package/src/gep/narrativeMemory.js +1 -1
- package/src/gep/openPRRegistry.js +1 -1
- package/src/gep/paths.js +16 -2
- package/src/gep/personality.js +1 -1
- package/src/gep/policyCheck.js +1 -1
- package/src/gep/prompt.js +1 -1
- package/src/gep/recallVerifier.js +1 -1
- package/src/gep/reflection.js +1 -1
- package/src/gep/selector.js +1 -1
- package/src/gep/skillDistiller.js +1 -1
- package/src/gep/solidify.js +1 -1
- package/src/gep/strategy.js +1 -1
- package/src/gep/validator/index.js +46 -1
- package/src/gep/validator/sandboxExecutor.js +10 -1
- package/src/gep/validator/stakeBootstrap.js +3 -0
- package/src/gep/workspaceKeychain.js +1 -1
- package/src/ops/lifecycle.js +79 -10
- package/src/ops/skills_monitor.js +2 -1
- package/src/proxy/index.js +7 -1
- package/src/proxy/lifecycle/manager.js +77 -4
- package/src/proxy/mailbox/store.js +52 -2
- package/src/proxy/server/settings.js +16 -2
- package/src/proxy/sync/inbound.js +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evomap/evolver",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.88.0",
|
|
4
4
|
"description": "A GEP-powered self-evolution engine for AI agents. Features automated log analysis and Genome Evolution Protocol (GEP) for auditable, reusable evolution assets.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -249,8 +249,18 @@ if (!OPTS.skipObfuscate) {
|
|
|
249
249
|
};
|
|
250
250
|
|
|
251
251
|
const MAX_OBF_ATTEMPTS_RAW = process.env.OBF_MAX_ATTEMPTS;
|
|
252
|
+
// Default 12 (was 4). The obfuscator's new.target -> #target mangling is
|
|
253
|
+
// non-deterministic ACROSS PROCESSES, not just across seeds: the same seed
|
|
254
|
+
// + same input can pass in one node process and fail in another (Set
|
|
255
|
+
// iteration / internal-state timing). So perturbing the seed per attempt is
|
|
256
|
+
// not the real lever — re-running the obfuscate call is. The v1.87.4 deploy
|
|
257
|
+
// hit 4/4 consecutive failures with the default of 4 and aborted the npm
|
|
258
|
+
// publish + binary upload. At an observed per-attempt failure rate that can
|
|
259
|
+
// run well above the historical ~5% for some bundles, 4 retries is too few;
|
|
260
|
+
// 12 drives the all-fail probability to negligible while costing only extra
|
|
261
|
+
// attempts on the rare unlucky run. Override with OBF_MAX_ATTEMPTS.
|
|
252
262
|
const MAX_OBF_ATTEMPTS = MAX_OBF_ATTEMPTS_RAW === undefined
|
|
253
|
-
?
|
|
263
|
+
? 12
|
|
254
264
|
: parseInt(MAX_OBF_ATTEMPTS_RAW, 10);
|
|
255
265
|
if (!Number.isInteger(MAX_OBF_ATTEMPTS) || MAX_OBF_ATTEMPTS < 1) {
|
|
256
266
|
console.error(` ERROR: OBF_MAX_ATTEMPTS must be a positive integer; got ${JSON.stringify(MAX_OBF_ATTEMPTS_RAW)}.`);
|
|
@@ -189,7 +189,9 @@ function copyHookScripts(destDir, evolverRoot) {
|
|
|
189
189
|
// closes the per-file hole.
|
|
190
190
|
assertNotSymlink(dest, `hook destination ${name}`);
|
|
191
191
|
fs.copyFileSync(src, dest);
|
|
192
|
-
|
|
192
|
+
// NOTE(windows): fs.chmodSync is a no-op on Windows; hook scripts remain
|
|
193
|
+
// executable via file extension association (.js), not Unix mode bits.
|
|
194
|
+
try { fs.chmodSync(dest, 0o755); } catch { /* best-effort; no-op on Windows */ }
|
|
193
195
|
copied.push(dest);
|
|
194
196
|
}
|
|
195
197
|
return copied;
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
const fs = require('fs');
|
|
20
20
|
const path = require('path');
|
|
21
21
|
const os = require('os');
|
|
22
|
+
const { spawnSync } = require('child_process');
|
|
22
23
|
|
|
23
24
|
function isEvolverPackageJson(filePath) {
|
|
24
25
|
try {
|
|
@@ -58,12 +59,36 @@ function findEvolverRoot() {
|
|
|
58
59
|
// `evolver-session-start.js`'s `additionalContext`. Restrict to trusted,
|
|
59
60
|
// user/system-scoped install roots.
|
|
60
61
|
try {
|
|
62
|
+
// Windows: `npm install -g` puts packages under %APPDATA%\npm\node_modules
|
|
63
|
+
// (most common), %ProgramFiles%\nodejs\node_modules (system-wide installer),
|
|
64
|
+
// or %ProgramFiles(x86)%\nodejs\node_modules. Build the extra Windows paths
|
|
65
|
+
// conditionally so the POSIX base list stays intact.
|
|
66
|
+
const _winPaths = process.platform === 'win32'
|
|
67
|
+
? [
|
|
68
|
+
path.join(
|
|
69
|
+
process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'),
|
|
70
|
+
'npm', 'node_modules'
|
|
71
|
+
),
|
|
72
|
+
...(process.env.ProgramFiles
|
|
73
|
+
? [path.join(process.env.ProgramFiles, 'nodejs', 'node_modules')]
|
|
74
|
+
: []),
|
|
75
|
+
...(process.env['ProgramFiles(x86)']
|
|
76
|
+
? [path.join(process.env['ProgramFiles(x86)'], 'nodejs', 'node_modules')]
|
|
77
|
+
: []),
|
|
78
|
+
]
|
|
79
|
+
: [];
|
|
80
|
+
|
|
61
81
|
const pkgJson = require.resolve('@evomap/evolver/package.json', {
|
|
82
|
+
// Do NOT include process.cwd() — a hostile workspace can plant its own
|
|
83
|
+
// node_modules/@evomap/evolver to gain control over the memory graph path
|
|
84
|
+
// (prompt-injection surface: evolver-session-start.js additionalContext).
|
|
85
|
+
// Only trust user/system-scoped install roots.
|
|
62
86
|
paths: [
|
|
63
87
|
path.join(os.homedir(), '.npm-global', 'lib', 'node_modules'),
|
|
64
88
|
path.join(os.homedir(), '.local', 'lib', 'node_modules'),
|
|
65
89
|
'/usr/lib/node_modules',
|
|
66
90
|
'/usr/local/lib/node_modules',
|
|
91
|
+
..._winPaths,
|
|
67
92
|
],
|
|
68
93
|
});
|
|
69
94
|
if (pkgJson && isEvolverPackageJson(pkgJson)) {
|
|
@@ -80,6 +105,162 @@ function findEvolverRoot() {
|
|
|
80
105
|
return null;
|
|
81
106
|
}
|
|
82
107
|
|
|
108
|
+
// Resolve the user's PROJECT directory — the workspace the agent is actually
|
|
109
|
+
// working in — for git-diff collection and workspace tagging.
|
|
110
|
+
//
|
|
111
|
+
// Why this exists: hook scripts must NOT assume `process.cwd()` is the project
|
|
112
|
+
// root. Cursor invokes some hook events (e.g. afterFileEdit) with the working
|
|
113
|
+
// directory set to the *plugin* install dir (`~/.cursor/plugins/local/<name>`),
|
|
114
|
+
// not the opened workspace. A hook that runs `git diff` in cwd would then look
|
|
115
|
+
// for changes in the plugin directory and find none — silently recording
|
|
116
|
+
// nothing for every task. Hosts expose the real workspace root via an env var:
|
|
117
|
+
// - Cursor sets CURSOR_PROJECT_DIR (and a CLAUDE_PROJECT_DIR compat alias)
|
|
118
|
+
// - Claude Code sets CLAUDE_PROJECT_DIR
|
|
119
|
+
// Codex / opencode / Kiro and direct CLI usage leave both unset, in which case
|
|
120
|
+
// `process.cwd()` is already the project root and remains the fallback — so
|
|
121
|
+
// this change is a no-op on those platforms.
|
|
122
|
+
//
|
|
123
|
+
// SECURITY: only honor an env value that points at an existing directory. A
|
|
124
|
+
// stale or empty value must not redirect git collection to a bogus path; we
|
|
125
|
+
// fall through to cwd instead. We intentionally do NOT recurse into evolver
|
|
126
|
+
// package discovery here — this is purely "where is the user's code".
|
|
127
|
+
function resolveProjectDir() {
|
|
128
|
+
for (const key of ['CURSOR_PROJECT_DIR', 'CLAUDE_PROJECT_DIR']) {
|
|
129
|
+
const v = process.env[key];
|
|
130
|
+
if (typeof v === 'string' && v.trim()) {
|
|
131
|
+
try {
|
|
132
|
+
if (fs.statSync(v).isDirectory()) return v;
|
|
133
|
+
} catch { /* not a usable dir — try next / fall back to cwd */ }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return process.cwd();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Determine the workspace ROOT for a project, mirroring src/gep/paths.js
|
|
140
|
+
// getWorkspaceRoot() step-for-step so the FS-only fallback lands its secret at
|
|
141
|
+
// the SAME path paths.js would (what lets an installed @evomap/evolver read the
|
|
142
|
+
// very same id):
|
|
143
|
+
// 1. OPENCLAW_WORKSPACE override.
|
|
144
|
+
// 2. else the git repo root at/above projectDir, BUT if that repo root has a
|
|
145
|
+
// `workspace/` subdirectory, paths.js returns <repoRoot>/workspace — so we
|
|
146
|
+
// must too, or the two land on different .evolver/workspace-id files (the
|
|
147
|
+
// "read back identically" guarantee would break for such projects).
|
|
148
|
+
// 3. else projectDir.
|
|
149
|
+
function _fsWorkspaceRoot(projectDir) {
|
|
150
|
+
if (process.env.OPENCLAW_WORKSPACE) return process.env.OPENCLAW_WORKSPACE;
|
|
151
|
+
// Walk up from projectDir looking for a .git entry (file or dir) = repo root.
|
|
152
|
+
let repoRoot = null;
|
|
153
|
+
let dir = projectDir;
|
|
154
|
+
while (dir) {
|
|
155
|
+
if (fs.existsSync(path.join(dir, '.git'))) { repoRoot = dir; break; }
|
|
156
|
+
const parent = path.dirname(dir);
|
|
157
|
+
if (parent === dir) break;
|
|
158
|
+
dir = parent;
|
|
159
|
+
}
|
|
160
|
+
if (!repoRoot) return projectDir;
|
|
161
|
+
// Mirror getWorkspaceRoot()'s workspace/ subdir step.
|
|
162
|
+
const workspaceDir = path.join(repoRoot, 'workspace');
|
|
163
|
+
if (fs.existsSync(workspaceDir)) return workspaceDir;
|
|
164
|
+
return repoRoot;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// FS-only re-implementation of src/gep/paths.js getWorkspaceId() for the case
|
|
168
|
+
// where the evolver package is not installed (plugin-only installs). It reads
|
|
169
|
+
// — and lazily, atomically creates — the per-workspace secret at
|
|
170
|
+
// <workspaceRoot>/.evolver/workspace-id. The format (16-byte hex), the path,
|
|
171
|
+
// the 0600 mode, the O_EXCL|O_NOFOLLOW atomic create, and the symlink
|
|
172
|
+
// rejection all match paths.js exactly, so a workspace seeded by this fallback
|
|
173
|
+
// is transparently picked up by paths.getWorkspaceId() once the package is
|
|
174
|
+
// present, and vice-versa. Returns null on any read/write error (caller then
|
|
175
|
+
// falls back to legacy cwd-tag matching — no regression).
|
|
176
|
+
// Read <dir>/workspace-id with the same symlink guards paths.js'
|
|
177
|
+
// _readWorkspaceIdFromFs uses: reject a symlinked .evolver dir, reject a
|
|
178
|
+
// symlinked / non-regular id file, and require hex format. Returns the id, or
|
|
179
|
+
// null on any error / missing file. Used for BOTH the initial read and the
|
|
180
|
+
// EEXIST race re-read so a symlink swapped in between our lstat and openSync
|
|
181
|
+
// can never be followed (Bugbot PR #557).
|
|
182
|
+
function _readWsIdGuarded(dir, file) {
|
|
183
|
+
try {
|
|
184
|
+
const dirStat = fs.lstatSync(dir, { throwIfNoEntry: false });
|
|
185
|
+
if (dirStat && dirStat.isSymbolicLink()) return null;
|
|
186
|
+
const fileStat = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
187
|
+
if (!fileStat) return null;
|
|
188
|
+
if (fileStat.isSymbolicLink() || !fileStat.isFile()) return null;
|
|
189
|
+
const raw = fs.readFileSync(file, 'utf8').trim();
|
|
190
|
+
return raw && /^[a-f0-9]{32,}$/i.test(raw) ? raw : null;
|
|
191
|
+
} catch { return null; }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function _fsWorkspaceId(projectDir) {
|
|
195
|
+
// Whole body is wrapped: the documented contract is "returns null on ANY
|
|
196
|
+
// read/write error" so the session-start/-end hooks degrade gracefully
|
|
197
|
+
// rather than crash. throwIfNoEntry:false only suppresses ENOENT; EACCES/EIO
|
|
198
|
+
// and friends still throw, so a bare lstat/mkdir here must not escape
|
|
199
|
+
// (Bugbot PR #557 round-2 — an unguarded lstat could crash the hook).
|
|
200
|
+
try {
|
|
201
|
+
const dir = path.join(_fsWorkspaceRoot(projectDir), '.evolver');
|
|
202
|
+
const file = path.join(dir, 'workspace-id');
|
|
203
|
+
// Read first, with symlink guards.
|
|
204
|
+
const existing = _readWsIdGuarded(dir, file);
|
|
205
|
+
if (existing) return existing;
|
|
206
|
+
// If the file exists but the guards rejected it (symlink / bad format),
|
|
207
|
+
// refuse rather than create over it.
|
|
208
|
+
if (fs.lstatSync(file, { throwIfNoEntry: false })) return null;
|
|
209
|
+
// Missing — create atomically. Refuse a symlinked .evolver dir (O_NOFOLLOW
|
|
210
|
+
// only guards the final component, not intermediate dirs).
|
|
211
|
+
const dirStat = fs.lstatSync(dir, { throwIfNoEntry: false });
|
|
212
|
+
if (dirStat && dirStat.isSymbolicLink()) return null;
|
|
213
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
214
|
+
const payload = require('crypto').randomBytes(16).toString('hex');
|
|
215
|
+
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL |
|
|
216
|
+
(fs.constants.O_NOFOLLOW || 0);
|
|
217
|
+
let fd;
|
|
218
|
+
try {
|
|
219
|
+
fd = fs.openSync(file, flags, 0o600);
|
|
220
|
+
} catch (e) {
|
|
221
|
+
// Lost a race — re-read WITH the same symlink guards (paths.js does the
|
|
222
|
+
// same). A bare readFileSync here would follow a symlink swapped in
|
|
223
|
+
// after our dir lstat (Bugbot PR #557).
|
|
224
|
+
if (e && e.code === 'EEXIST') return _readWsIdGuarded(dir, file);
|
|
225
|
+
return null; // ELOOP/EMLINK from O_NOFOLLOW hitting a symlink — refuse.
|
|
226
|
+
}
|
|
227
|
+
try { fs.writeSync(fd, payload + '\n', 0, 'utf8'); } finally { fs.closeSync(fd); }
|
|
228
|
+
try { fs.chmodSync(file, 0o600); } catch { /* best-effort */ }
|
|
229
|
+
return payload;
|
|
230
|
+
} catch { return null; }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Resolve the current workspace id — the forge-resistant tag the session-end
|
|
234
|
+
// writer stamps on every memory-graph entry (`workspace_id`). This is the
|
|
235
|
+
// SINGLE source of that resolution: the session-end writer stamps it and the
|
|
236
|
+
// session-start reader scopes by it, so both call this one function. Keeping
|
|
237
|
+
// it here (rather than a copy per hook) is what guarantees reader and writer
|
|
238
|
+
// can never drift apart — if they resolved different ids, no entry would ever
|
|
239
|
+
// match the reader's filter and workspace scoping would silently break.
|
|
240
|
+
// Resolution order:
|
|
241
|
+
// 1. EVOLVER_WORKSPACE_ID env override
|
|
242
|
+
// 2. paths.getWorkspaceId() loaded from the resolved evolver root (this is
|
|
243
|
+
// the richer path — it can additionally back the secret with the OS
|
|
244
|
+
// keychain when @napi-rs/keyring is installed).
|
|
245
|
+
// 3. FS-only fallback for plugin-only installs where the evolver package is
|
|
246
|
+
// not reachable. Without this, plugin users got workspace_id=null and the
|
|
247
|
+
// forge-resistant scoping silently degraded to cwd-tag matching (found
|
|
248
|
+
// via real-Cursor end-to-end testing). The fallback writes the same
|
|
249
|
+
// secret file paths.js uses, so installing the package later is seamless.
|
|
250
|
+
// Still returns null if even the FS write fails — callers must then NOT filter
|
|
251
|
+
// (show everything), preserving prior behavior rather than hiding all memory.
|
|
252
|
+
function resolveWorkspaceId(evolverRoot, projectDir) {
|
|
253
|
+
if (process.env.EVOLVER_WORKSPACE_ID) return String(process.env.EVOLVER_WORKSPACE_ID);
|
|
254
|
+
const root = evolverRoot || findEvolverRoot();
|
|
255
|
+
if (root) {
|
|
256
|
+
try {
|
|
257
|
+
const paths = require(path.join(root, 'src', 'gep', 'paths.js'));
|
|
258
|
+
if (typeof paths.getWorkspaceId === 'function') return paths.getWorkspaceId();
|
|
259
|
+
} catch { /* paths.js unreachable — fall through to FS-only */ }
|
|
260
|
+
}
|
|
261
|
+
return _fsWorkspaceId(projectDir || resolveProjectDir());
|
|
262
|
+
}
|
|
263
|
+
|
|
83
264
|
// Returns a path to the evolution memory graph, or a fallback location that
|
|
84
265
|
// is guaranteed to be writable. Never returns null — when no evolver root is
|
|
85
266
|
// available, we fall back to `~/.evolver/memory/evolution/memory_graph.jsonl`
|
|
@@ -111,4 +292,24 @@ function findMemoryGraph(evolverRoot) {
|
|
|
111
292
|
return path.join(userDir, 'memory_graph.jsonl');
|
|
112
293
|
}
|
|
113
294
|
|
|
114
|
-
|
|
295
|
+
// Is `dir` inside a git work tree? Cheap, no-shell `git rev-parse`. Returns
|
|
296
|
+
// false on any error (git missing, not a repo, timeout) and never throws — the
|
|
297
|
+
// session-start hook uses this only to decide whether to surface a one-line
|
|
298
|
+
// "evolver needs a git workspace" notice, so a false negative just suppresses
|
|
299
|
+
// the notice rather than breaking anything.
|
|
300
|
+
function isGitWorkspace(dir) {
|
|
301
|
+
try {
|
|
302
|
+
const res = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
|
|
303
|
+
cwd: dir,
|
|
304
|
+
encoding: 'utf8',
|
|
305
|
+
timeout: 5000,
|
|
306
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
307
|
+
shell: false,
|
|
308
|
+
});
|
|
309
|
+
return res.status === 0 && typeof res.stdout === 'string' && res.stdout.trim() === 'true';
|
|
310
|
+
} catch {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
module.exports = { findEvolverRoot, findMemoryGraph, resolveProjectDir, resolveWorkspaceId, isGitWorkspace };
|
|
@@ -8,32 +8,23 @@
|
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const path = require('path');
|
|
10
10
|
const os = require('os');
|
|
11
|
+
const https = require('https');
|
|
12
|
+
const http = require('http');
|
|
11
13
|
const { spawnSync } = require('child_process');
|
|
12
14
|
// 10 MB — prevents RangeError on large child process output (e.g. git log/diff
|
|
13
15
|
// on large repos). See GHSA reports / issue #451.
|
|
14
16
|
const MAX_EXEC_BUFFER = 10 * 1024 * 1024;
|
|
15
17
|
|
|
16
|
-
const { findEvolverRoot, findMemoryGraph } = require('./_runtimePaths');
|
|
18
|
+
const { findEvolverRoot, findMemoryGraph, resolveProjectDir, resolveWorkspaceId } = require('./_runtimePaths');
|
|
17
19
|
|
|
18
|
-
// Workspace-id
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// round-1 MEDIUM
|
|
26
|
-
// evolver root; fall back to env-only when paths.js is unreachable.
|
|
27
|
-
function resolveWorkspaceIdForWriter() {
|
|
28
|
-
if (process.env.EVOLVER_WORKSPACE_ID) return String(process.env.EVOLVER_WORKSPACE_ID);
|
|
29
|
-
const evolverRoot = findEvolverRoot();
|
|
30
|
-
if (!evolverRoot) return null;
|
|
31
|
-
try {
|
|
32
|
-
const paths = require(path.join(evolverRoot, 'src', 'gep', 'paths.js'));
|
|
33
|
-
if (typeof paths.getWorkspaceId === 'function') return paths.getWorkspaceId();
|
|
34
|
-
} catch { /* paths.js unreachable — return null */ }
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
20
|
+
// Workspace-id resolution is shared with the session-start reader via
|
|
21
|
+
// _runtimePaths.resolveWorkspaceId(). Reader and writer MUST resolve the SAME
|
|
22
|
+
// id or workspace scoping silently breaks (no entry would ever match the
|
|
23
|
+
// reader's filter), so this logic lives in exactly one place instead of being
|
|
24
|
+
// duplicated here. The shared resolver mirrors src/gep/paths.js#getWorkspaceId()
|
|
25
|
+
// loaded from the evolver root, with an EVOLVER_WORKSPACE_ID env override —
|
|
26
|
+
// consistent with the review-time reader in src/evolve/pipeline/collect.js
|
|
27
|
+
// (Bugbot PR #109 round-1 MEDIUM; reader/writer drift flagged on PR #555).
|
|
37
28
|
|
|
38
29
|
function runGit(args, cwd) {
|
|
39
30
|
// Argv-array form, no shell. Avoids POSIX `2>/dev/null` redirects that
|
|
@@ -55,7 +46,9 @@ function runGit(args, cwd) {
|
|
|
55
46
|
}
|
|
56
47
|
|
|
57
48
|
function getGitDiffStats() {
|
|
58
|
-
|
|
49
|
+
// Use the host-provided workspace root, not process.cwd(): Cursor runs some
|
|
50
|
+
// hook events with cwd set to the plugin dir, where `git diff` finds nothing.
|
|
51
|
+
const cwd = resolveProjectDir();
|
|
59
52
|
// Distinguish "git failed (no HEAD~1, etc.)" from "git succeeded with
|
|
60
53
|
// empty output (e.g. empty merge)". The previous `||` chain treated
|
|
61
54
|
// both as falsy and fell through to the working-tree diff, which can
|
|
@@ -67,11 +60,16 @@ function getGitDiffStats() {
|
|
|
67
60
|
const filesChanged = (stat.match(/\d+ files? changed/) || ['0'])[0];
|
|
68
61
|
const insertions = (stat.match(/(\d+) insertions?/) || [null, '0'])[1];
|
|
69
62
|
const deletions = (stat.match(/(\d+) deletions?/) || [null, '0'])[1];
|
|
63
|
+
// Distinguish "no git repo here" from "repo with no changes" purely for the
|
|
64
|
+
// skip-log message — the diff commands above can't tell the two apart (both
|
|
65
|
+
// yield empty output). A single cheap rev-parse settles it.
|
|
66
|
+
const isRepo = runGit(['rev-parse', '--is-inside-work-tree'], cwd).out === 'true';
|
|
70
67
|
return {
|
|
71
68
|
stat,
|
|
72
69
|
summary: `${filesChanged}, +${insertions}/-${deletions}`,
|
|
73
70
|
diffSnippet: diffContent.slice(0, 2000),
|
|
74
71
|
hasChanges: stat.length > 0,
|
|
72
|
+
isRepo,
|
|
75
73
|
};
|
|
76
74
|
}
|
|
77
75
|
|
|
@@ -120,6 +118,9 @@ function recordToHub(outcome) {
|
|
|
120
118
|
const nodeId = process.env.EVOMAP_NODE_ID || process.env.A2A_NODE_ID;
|
|
121
119
|
if (!hubUrl || !apiKey) return false;
|
|
122
120
|
|
|
121
|
+
// Use Node.js built-in http/https instead of curl so this works on all
|
|
122
|
+
// platforms, including Windows where curl may not be available or may be
|
|
123
|
+
// an older, incompatible version bundled with some environments.
|
|
123
124
|
try {
|
|
124
125
|
const payload = JSON.stringify({
|
|
125
126
|
gene_id: outcome.geneId || 'ad_hoc',
|
|
@@ -129,22 +130,39 @@ function recordToHub(outcome) {
|
|
|
129
130
|
summary: outcome.summary,
|
|
130
131
|
sender_id: nodeId || undefined,
|
|
131
132
|
});
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
133
|
+
|
|
134
|
+
const endpoint = hubUrl.replace(/\/+$/, '') + '/a2a/evolution/record';
|
|
135
|
+
let parsedUrl;
|
|
136
|
+
try { parsedUrl = new URL(endpoint); } catch { return false; }
|
|
137
|
+
|
|
138
|
+
const isHttps = parsedUrl.protocol === 'https:';
|
|
139
|
+
const transport = isHttps ? https : http;
|
|
140
|
+
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
const req = transport.request(
|
|
143
|
+
{
|
|
144
|
+
hostname: parsedUrl.hostname,
|
|
145
|
+
port: parsedUrl.port || (isHttps ? 443 : 80),
|
|
146
|
+
path: parsedUrl.pathname + (parsedUrl.search || ''),
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: {
|
|
149
|
+
'Content-Type': 'application/json',
|
|
150
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
151
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
152
|
+
},
|
|
153
|
+
timeout: 8000,
|
|
154
|
+
},
|
|
155
|
+
(res) => {
|
|
156
|
+
// Drain the response to free the socket; we only care about status.
|
|
157
|
+
res.resume();
|
|
158
|
+
resolve(res.statusCode >= 200 && res.statusCode < 300);
|
|
159
|
+
}
|
|
160
|
+
);
|
|
161
|
+
req.on('error', () => resolve(false));
|
|
162
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
163
|
+
req.write(payload);
|
|
164
|
+
req.end();
|
|
145
165
|
});
|
|
146
|
-
if (res.status !== 0 || res.error) return false;
|
|
147
|
-
return true;
|
|
148
166
|
} catch {
|
|
149
167
|
return false;
|
|
150
168
|
}
|
|
@@ -152,6 +170,10 @@ function recordToHub(outcome) {
|
|
|
152
170
|
|
|
153
171
|
function recordToLocal(graphPath, outcome) {
|
|
154
172
|
try {
|
|
173
|
+
// Resolve the project dir once so the cwd tag and the workspace_id secret
|
|
174
|
+
// share a single, consistent source (both must agree with the session-start
|
|
175
|
+
// reader's resolveProjectDir()-based scoping).
|
|
176
|
+
const projectDir = resolveProjectDir();
|
|
155
177
|
const entry = {
|
|
156
178
|
timestamp: new Date().toISOString(),
|
|
157
179
|
gene_id: outcome.geneId || 'ad_hoc',
|
|
@@ -174,8 +196,17 @@ function recordToLocal(graphPath, outcome) {
|
|
|
174
196
|
// .evolver/workspace-id file. cwd is retained as a backward-compat
|
|
175
197
|
// tag so older entries written before this hardening still pass
|
|
176
198
|
// the cwd check.
|
|
177
|
-
|
|
178
|
-
|
|
199
|
+
//
|
|
200
|
+
// Use resolveProjectDir() (NOT process.cwd()) so the cwd tag records the
|
|
201
|
+
// user's project, consistent with how the diff above is collected and
|
|
202
|
+
// with the session-start reader's cwd fallback. Under Cursor, cwd is the
|
|
203
|
+
// plugin install dir, so a raw process.cwd() tag would never match the
|
|
204
|
+
// reader's resolveProjectDir()-derived currentDir — silently hiding every
|
|
205
|
+
// cwd-only entry (Bugbot PR #555). collect.js only uses cwd as a legacy
|
|
206
|
+
// fallback (disabled once a workspace_id secret exists), so changing the
|
|
207
|
+
// tag's source — still a directory path — does not affect its scoping.
|
|
208
|
+
cwd: projectDir,
|
|
209
|
+
workspace_id: resolveWorkspaceId(undefined, projectDir),
|
|
179
210
|
source: 'hook:session-end',
|
|
180
211
|
};
|
|
181
212
|
fs.appendFileSync(graphPath, JSON.stringify(entry) + '\n', 'utf8');
|
|
@@ -185,6 +216,24 @@ function recordToLocal(graphPath, outcome) {
|
|
|
185
216
|
}
|
|
186
217
|
}
|
|
187
218
|
|
|
219
|
+
// Append a single timestamped line to ~/.evolver/logs/evolution.log (or
|
|
220
|
+
// EVOLVER_HOOK_LOG_DIR). Best-effort: a log-write failure must never break the
|
|
221
|
+
// hook. Used both for recorded outcomes and for the "skipped, nothing to
|
|
222
|
+
// record" notices so a user can always see why a session did or did not
|
|
223
|
+
// produce an entry.
|
|
224
|
+
function appendEvolutionLog(line) {
|
|
225
|
+
try {
|
|
226
|
+
const logDir = process.env.EVOLVER_HOOK_LOG_DIR
|
|
227
|
+
|| path.join(os.homedir(), '.evolver', 'logs');
|
|
228
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
229
|
+
fs.appendFileSync(
|
|
230
|
+
path.join(logDir, 'evolution.log'),
|
|
231
|
+
`${new Date().toISOString()} ${line}\n`,
|
|
232
|
+
'utf8'
|
|
233
|
+
);
|
|
234
|
+
} catch { /* best-effort, never break the hook on log write */ }
|
|
235
|
+
}
|
|
236
|
+
|
|
188
237
|
function main() {
|
|
189
238
|
let inputData = '';
|
|
190
239
|
let handled = false;
|
|
@@ -200,75 +249,88 @@ function main() {
|
|
|
200
249
|
process.stdin.on('data', chunk => { inputData += chunk; });
|
|
201
250
|
process.stdin.on('end', () => {
|
|
202
251
|
if (handled) return;
|
|
203
|
-
|
|
204
|
-
|
|
252
|
+
// recordToHub is async (uses Node.js http/https); wrap the rest of the
|
|
253
|
+
// handler in an immediately-invoked async function so we can await it
|
|
254
|
+
// while still honouring the watchdog timeout and the `handled` guard.
|
|
255
|
+
(async () => {
|
|
256
|
+
try {
|
|
257
|
+
const diffInfo = getGitDiffStats();
|
|
205
258
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
259
|
+
if (!diffInfo.hasChanges) {
|
|
260
|
+
// No git diff means no signal source — session-end derives the
|
|
261
|
+
// outcome (status/score/signals/summary) entirely from the diff, so
|
|
262
|
+
// there is nothing meaningful to record. This is expected in a
|
|
263
|
+
// non-git workspace or a repo with no changes this session. Rather
|
|
264
|
+
// than fabricate an empty outcome (which would pollute the memory
|
|
265
|
+
// graph), record nothing — but leave a log breadcrumb so the user
|
|
266
|
+
// can tell "evolver ran but had nothing to record" apart from
|
|
267
|
+
// "evolver never fired".
|
|
268
|
+
const reason = diffInfo.isRepo
|
|
269
|
+
? 'no changes detected this session'
|
|
270
|
+
: 'not a git workspace';
|
|
271
|
+
appendEvolutionLog(`[Evolution] Session end: nothing recorded (${reason}).`);
|
|
272
|
+
finish({});
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
210
275
|
|
|
211
|
-
|
|
212
|
-
|
|
276
|
+
const signals = detectSignals(diffInfo.diffSnippet);
|
|
277
|
+
if (signals.length === 0) signals.push('stable_success_plateau');
|
|
213
278
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
279
|
+
const hasErrors = signals.includes('log_error') || signals.includes('test_failure');
|
|
280
|
+
const status = hasErrors ? 'failed' : 'success';
|
|
281
|
+
const score = hasErrors ? 0.3 : 0.8;
|
|
217
282
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
283
|
+
const outcome = {
|
|
284
|
+
geneId: 'ad_hoc',
|
|
285
|
+
signals,
|
|
286
|
+
status,
|
|
287
|
+
score,
|
|
288
|
+
summary: `Session end: ${diffInfo.summary}. Signals: [${signals.join(', ')}]`,
|
|
289
|
+
};
|
|
225
290
|
|
|
226
|
-
|
|
227
|
-
|
|
291
|
+
const evolverRoot = findEvolverRoot();
|
|
292
|
+
const graphPath = findMemoryGraph(evolverRoot);
|
|
228
293
|
|
|
229
|
-
|
|
230
|
-
|
|
294
|
+
// Local first: recordToHub is async with an 8s socket timeout, but
|
|
295
|
+
// the 7s watchdog (setTimeout below) will process.exit(0) before a
|
|
296
|
+
// slow hub returns — so anything sequenced after `await recordToHub`
|
|
297
|
+
// can be silently skipped. recordToLocal is the reliable offline
|
|
298
|
+
// fallback and must run regardless of hub latency.
|
|
299
|
+
const localOk = graphPath ? recordToLocal(graphPath, outcome) : false;
|
|
300
|
+
const hubOk = await recordToHub(outcome);
|
|
231
301
|
|
|
232
|
-
|
|
233
|
-
|
|
302
|
+
const target = hubOk ? 'Hub' : localOk ? 'local memory' : 'nowhere (no Hub or local path)';
|
|
303
|
+
const msg = `[Evolution] Session outcome recorded to ${target}: ${outcome.summary}`;
|
|
234
304
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
const logDir = process.env.EVOLVER_HOOK_LOG_DIR
|
|
259
|
-
|| path.join(os.homedir(), '.evolver', 'logs');
|
|
260
|
-
fs.mkdirSync(logDir, { recursive: true });
|
|
261
|
-
fs.appendFileSync(
|
|
262
|
-
path.join(logDir, 'evolution.log'),
|
|
263
|
-
`${new Date().toISOString()} ${msg}\n`,
|
|
264
|
-
'utf8'
|
|
265
|
-
);
|
|
266
|
-
} catch { /* best-effort, never break the hook on log write */ }
|
|
305
|
+
// Stop hook output schema (per Claude Code docs):
|
|
306
|
+
// - decision: "approve" | "block"
|
|
307
|
+
// - reason: string (shown when decision is set)
|
|
308
|
+
// - systemMessage: string (notification displayed to user)
|
|
309
|
+
// - continue: boolean
|
|
310
|
+
// - stopReason: string
|
|
311
|
+
//
|
|
312
|
+
// Earlier versions emitted `followup_message`, `stopMessage`, and
|
|
313
|
+
// `additionalContext` together. `followup_message` is the field that
|
|
314
|
+
// re-injects the receipt into Claude's next inference round, which
|
|
315
|
+
// caused the agent to "respond" to its own evolution log line —
|
|
316
|
+
// visible to users as an unexplained extra reasoning turn after
|
|
317
|
+
// every task. The evolver is supposed to be observational, so we
|
|
318
|
+
// now use `systemMessage` only — that surfaces the receipt to the
|
|
319
|
+
// user without forcing another inference round.
|
|
320
|
+
//
|
|
321
|
+
// Cursor compatibility: Cursor's Claude Code-compatible runtime
|
|
322
|
+
// currently treats `systemMessage` as a user prompt for the next
|
|
323
|
+
// inference round. When we detect Cursor, omit systemMessage too.
|
|
324
|
+
// The receipt is always appended to ~/.evolver/logs/evolution.log
|
|
325
|
+
// so it is never silently lost; users can opt back in to the inline
|
|
326
|
+
// notification with EVOLVER_HOOK_VERBOSE=1.
|
|
327
|
+
appendEvolutionLog(msg);
|
|
267
328
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
329
|
+
finish(isCursorHost() ? {} : { systemMessage: msg });
|
|
330
|
+
} catch (e) {
|
|
331
|
+
finish({});
|
|
332
|
+
}
|
|
333
|
+
})();
|
|
272
334
|
});
|
|
273
335
|
|
|
274
336
|
watchdog = setTimeout(() => finish({}), 7000);
|