@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.
Files changed (65) hide show
  1. package/index.js +848 -33
  2. package/package.json +1 -1
  3. package/scripts/build_binaries.js +11 -1
  4. package/src/adapters/hookAdapter.js +3 -1
  5. package/src/adapters/scripts/_runtimePaths.js +202 -1
  6. package/src/adapters/scripts/evolver-session-end.js +160 -98
  7. package/src/adapters/scripts/evolver-session-start.js +227 -43
  8. package/src/config.js +43 -8
  9. package/src/evolve/guards.js +1 -1
  10. package/src/evolve/pipeline/collect.js +1 -1
  11. package/src/evolve/pipeline/dispatch.js +1 -1
  12. package/src/evolve/pipeline/enrich.js +1 -1
  13. package/src/evolve/pipeline/hub.js +1 -1
  14. package/src/evolve/pipeline/select.js +1 -1
  15. package/src/evolve/pipeline/signals.js +1 -1
  16. package/src/evolve/utils.js +1 -1
  17. package/src/evolve.js +1 -1
  18. package/src/forceUpdate.js +42 -21
  19. package/src/gep/a2aProtocol.js +1 -1
  20. package/src/gep/assetStore.js +40 -0
  21. package/src/gep/candidateEval.js +1 -1
  22. package/src/gep/candidates.js +1 -1
  23. package/src/gep/contentHash.js +1 -1
  24. package/src/gep/crypto.js +1 -1
  25. package/src/gep/curriculum.js +1 -1
  26. package/src/gep/deviceId.js +1 -1
  27. package/src/gep/envFingerprint.js +1 -1
  28. package/src/gep/epigenetics.js +1 -1
  29. package/src/gep/explore.js +1 -1
  30. package/src/gep/featureFlags.js +4 -0
  31. package/src/gep/gitOps.js +7 -2
  32. package/src/gep/hash.js +1 -1
  33. package/src/gep/hubFetch.js +1 -1
  34. package/src/gep/hubReview.js +1 -1
  35. package/src/gep/hubSearch.js +1 -1
  36. package/src/gep/hubVerify.js +1 -1
  37. package/src/gep/idleScheduler.js +233 -6
  38. package/src/gep/learningSignals.js +1 -1
  39. package/src/gep/mailboxTransport.js +34 -0
  40. package/src/gep/memoryGraph.js +1 -1
  41. package/src/gep/memoryGraphAdapter.js +1 -1
  42. package/src/gep/mutation.js +1 -1
  43. package/src/gep/narrativeMemory.js +1 -1
  44. package/src/gep/openPRRegistry.js +1 -1
  45. package/src/gep/paths.js +16 -2
  46. package/src/gep/personality.js +1 -1
  47. package/src/gep/policyCheck.js +1 -1
  48. package/src/gep/prompt.js +1 -1
  49. package/src/gep/recallVerifier.js +1 -1
  50. package/src/gep/reflection.js +1 -1
  51. package/src/gep/selector.js +1 -1
  52. package/src/gep/skillDistiller.js +1 -1
  53. package/src/gep/solidify.js +1 -1
  54. package/src/gep/strategy.js +1 -1
  55. package/src/gep/validator/index.js +46 -1
  56. package/src/gep/validator/sandboxExecutor.js +10 -1
  57. package/src/gep/validator/stakeBootstrap.js +3 -0
  58. package/src/gep/workspaceKeychain.js +1 -1
  59. package/src/ops/lifecycle.js +79 -10
  60. package/src/ops/skills_monitor.js +2 -1
  61. package/src/proxy/index.js +7 -1
  62. package/src/proxy/lifecycle/manager.js +77 -4
  63. package/src/proxy/mailbox/store.js +52 -2
  64. package/src/proxy/server/settings.js +16 -2
  65. 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.87.3",
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
- ? 4
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
- try { fs.chmodSync(dest, 0o755); } catch { /* windows */ }
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
- module.exports = { findEvolverRoot, findMemoryGraph };
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 must use the same resolution as the reader in
19
- // src/evolve/pipeline/collect.js (which goes through src/gep/paths.js#
20
- // getWorkspaceRoot()). Otherwise writer and reader could land on
21
- // different `.evolver/workspace-id` files when EVOLVER_REPO_ROOT or
22
- // OPENCLAW_WORKSPACE is set, or when a `<repoRoot>/workspace`
23
- // subdirectory exists in which case the IDs would never match and
24
- // every memory-graph entry would silently get dropped (Bugbot PR #109
25
- // round-1 MEDIUM). Lazy-load the canonical resolver from the resolved
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
- const cwd = process.cwd();
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
- // Argv-array form avoids shell interpretation of apiKey, payload, or the
133
- // hub URL. Values cannot break out through shell metacharacters.
134
- const res = spawnSync('curl', [
135
- '-s', '-m', '8', '-X', 'POST',
136
- '-H', 'Content-Type: application/json',
137
- '-H', `Authorization: Bearer ${apiKey}`,
138
- '-d', payload,
139
- `${hubUrl.replace(/\/+$/, '')}/a2a/evolution/record`,
140
- ], {
141
- timeout: 10000,
142
- stdio: ['pipe', 'pipe', 'pipe'],
143
- maxBuffer: MAX_EXEC_BUFFER,
144
- shell: false,
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
- cwd: process.cwd(),
178
- workspace_id: resolveWorkspaceIdForWriter(),
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
- try {
204
- const diffInfo = getGitDiffStats();
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
- if (!diffInfo.hasChanges) {
207
- finish({});
208
- return;
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
- const signals = detectSignals(diffInfo.diffSnippet);
212
- if (signals.length === 0) signals.push('stable_success_plateau');
276
+ const signals = detectSignals(diffInfo.diffSnippet);
277
+ if (signals.length === 0) signals.push('stable_success_plateau');
213
278
 
214
- const hasErrors = signals.includes('log_error') || signals.includes('test_failure');
215
- const status = hasErrors ? 'failed' : 'success';
216
- const score = hasErrors ? 0.3 : 0.8;
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
- const outcome = {
219
- geneId: 'ad_hoc',
220
- signals,
221
- status,
222
- score,
223
- summary: `Session end: ${diffInfo.summary}. Signals: [${signals.join(', ')}]`,
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
- const evolverRoot = findEvolverRoot();
227
- const graphPath = findMemoryGraph(evolverRoot);
291
+ const evolverRoot = findEvolverRoot();
292
+ const graphPath = findMemoryGraph(evolverRoot);
228
293
 
229
- const hubOk = recordToHub(outcome);
230
- const localOk = graphPath ? recordToLocal(graphPath, outcome) : false;
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
- const target = hubOk ? 'Hub' : localOk ? 'local memory' : 'nowhere (no Hub or local path)';
233
- const msg = `[Evolution] Session outcome recorded to ${target}: ${outcome.summary}`;
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
- // Stop hook output schema (per Claude Code docs):
236
- // - decision: "approve" | "block"
237
- // - reason: string (shown when decision is set)
238
- // - systemMessage: string (notification displayed to user)
239
- // - continue: boolean
240
- // - stopReason: string
241
- //
242
- // Earlier versions emitted `followup_message`, `stopMessage`, and
243
- // `additionalContext` together. `followup_message` is the field that
244
- // re-injects the receipt into Claude's next inference round, which
245
- // caused the agent to "respond" to its own evolution log line —
246
- // visible to users as an unexplained extra reasoning turn after
247
- // every task. The evolver is supposed to be observational, so we
248
- // now use `systemMessage` only — that surfaces the receipt to the
249
- // user without forcing another inference round.
250
- //
251
- // Cursor compatibility: Cursor's Claude Code-compatible runtime
252
- // currently treats `systemMessage` as a user prompt for the next
253
- // inference round. When we detect Cursor, omit systemMessage too.
254
- // The receipt is always appended to ~/.evolver/logs/evolution.log
255
- // so it is never silently lost; users can opt back in to the inline
256
- // notification with EVOLVER_HOOK_VERBOSE=1.
257
- try {
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
- finish(isCursorHost() ? {} : { systemMessage: msg });
269
- } catch (e) {
270
- finish({});
271
- }
329
+ finish(isCursorHost() ? {} : { systemMessage: msg });
330
+ } catch (e) {
331
+ finish({});
332
+ }
333
+ })();
272
334
  });
273
335
 
274
336
  watchdog = setTimeout(() => finish({}), 7000);