@evomap/evolver 1.87.3 → 1.87.4

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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/adapters/scripts/_runtimePaths.js +178 -1
  3. package/src/adapters/scripts/evolver-session-end.js +63 -33
  4. package/src/adapters/scripts/evolver-session-start.js +127 -43
  5. package/src/evolve/guards.js +1 -1
  6. package/src/evolve/pipeline/collect.js +1 -1
  7. package/src/evolve/pipeline/dispatch.js +1 -1
  8. package/src/evolve/pipeline/enrich.js +1 -1
  9. package/src/evolve/pipeline/hub.js +1 -1
  10. package/src/evolve/pipeline/select.js +1 -1
  11. package/src/evolve/pipeline/signals.js +1 -1
  12. package/src/evolve/utils.js +1 -1
  13. package/src/evolve.js +1 -1
  14. package/src/gep/a2aProtocol.js +1 -1
  15. package/src/gep/candidateEval.js +1 -1
  16. package/src/gep/candidates.js +1 -1
  17. package/src/gep/contentHash.js +1 -1
  18. package/src/gep/crypto.js +1 -1
  19. package/src/gep/curriculum.js +1 -1
  20. package/src/gep/deviceId.js +1 -1
  21. package/src/gep/envFingerprint.js +1 -1
  22. package/src/gep/epigenetics.js +1 -1
  23. package/src/gep/explore.js +1 -1
  24. package/src/gep/hash.js +1 -1
  25. package/src/gep/hubFetch.js +1 -1
  26. package/src/gep/hubReview.js +1 -1
  27. package/src/gep/hubSearch.js +1 -1
  28. package/src/gep/hubVerify.js +1 -1
  29. package/src/gep/idleScheduler.js +155 -6
  30. package/src/gep/learningSignals.js +1 -1
  31. package/src/gep/memoryGraph.js +1 -1
  32. package/src/gep/memoryGraphAdapter.js +1 -1
  33. package/src/gep/mutation.js +1 -1
  34. package/src/gep/narrativeMemory.js +1 -1
  35. package/src/gep/openPRRegistry.js +1 -1
  36. package/src/gep/personality.js +1 -1
  37. package/src/gep/policyCheck.js +1 -1
  38. package/src/gep/prompt.js +1 -1
  39. package/src/gep/recallVerifier.js +1 -1
  40. package/src/gep/reflection.js +1 -1
  41. package/src/gep/selector.js +1 -1
  42. package/src/gep/skillDistiller.js +1 -1
  43. package/src/gep/solidify.js +1 -1
  44. package/src/gep/strategy.js +1 -1
  45. package/src/gep/workspaceKeychain.js +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evomap/evolver",
3
- "version": "1.87.3",
3
+ "version": "1.87.4",
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": {
@@ -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 {
@@ -80,6 +81,162 @@ function findEvolverRoot() {
80
81
  return null;
81
82
  }
82
83
 
84
+ // Resolve the user's PROJECT directory — the workspace the agent is actually
85
+ // working in — for git-diff collection and workspace tagging.
86
+ //
87
+ // Why this exists: hook scripts must NOT assume `process.cwd()` is the project
88
+ // root. Cursor invokes some hook events (e.g. afterFileEdit) with the working
89
+ // directory set to the *plugin* install dir (`~/.cursor/plugins/local/<name>`),
90
+ // not the opened workspace. A hook that runs `git diff` in cwd would then look
91
+ // for changes in the plugin directory and find none — silently recording
92
+ // nothing for every task. Hosts expose the real workspace root via an env var:
93
+ // - Cursor sets CURSOR_PROJECT_DIR (and a CLAUDE_PROJECT_DIR compat alias)
94
+ // - Claude Code sets CLAUDE_PROJECT_DIR
95
+ // Codex / opencode / Kiro and direct CLI usage leave both unset, in which case
96
+ // `process.cwd()` is already the project root and remains the fallback — so
97
+ // this change is a no-op on those platforms.
98
+ //
99
+ // SECURITY: only honor an env value that points at an existing directory. A
100
+ // stale or empty value must not redirect git collection to a bogus path; we
101
+ // fall through to cwd instead. We intentionally do NOT recurse into evolver
102
+ // package discovery here — this is purely "where is the user's code".
103
+ function resolveProjectDir() {
104
+ for (const key of ['CURSOR_PROJECT_DIR', 'CLAUDE_PROJECT_DIR']) {
105
+ const v = process.env[key];
106
+ if (typeof v === 'string' && v.trim()) {
107
+ try {
108
+ if (fs.statSync(v).isDirectory()) return v;
109
+ } catch { /* not a usable dir — try next / fall back to cwd */ }
110
+ }
111
+ }
112
+ return process.cwd();
113
+ }
114
+
115
+ // Determine the workspace ROOT for a project, mirroring src/gep/paths.js
116
+ // getWorkspaceRoot() step-for-step so the FS-only fallback lands its secret at
117
+ // the SAME path paths.js would (what lets an installed @evomap/evolver read the
118
+ // very same id):
119
+ // 1. OPENCLAW_WORKSPACE override.
120
+ // 2. else the git repo root at/above projectDir, BUT if that repo root has a
121
+ // `workspace/` subdirectory, paths.js returns <repoRoot>/workspace — so we
122
+ // must too, or the two land on different .evolver/workspace-id files (the
123
+ // "read back identically" guarantee would break for such projects).
124
+ // 3. else projectDir.
125
+ function _fsWorkspaceRoot(projectDir) {
126
+ if (process.env.OPENCLAW_WORKSPACE) return process.env.OPENCLAW_WORKSPACE;
127
+ // Walk up from projectDir looking for a .git entry (file or dir) = repo root.
128
+ let repoRoot = null;
129
+ let dir = projectDir;
130
+ while (dir) {
131
+ if (fs.existsSync(path.join(dir, '.git'))) { repoRoot = dir; break; }
132
+ const parent = path.dirname(dir);
133
+ if (parent === dir) break;
134
+ dir = parent;
135
+ }
136
+ if (!repoRoot) return projectDir;
137
+ // Mirror getWorkspaceRoot()'s workspace/ subdir step.
138
+ const workspaceDir = path.join(repoRoot, 'workspace');
139
+ if (fs.existsSync(workspaceDir)) return workspaceDir;
140
+ return repoRoot;
141
+ }
142
+
143
+ // FS-only re-implementation of src/gep/paths.js getWorkspaceId() for the case
144
+ // where the evolver package is not installed (plugin-only installs). It reads
145
+ // — and lazily, atomically creates — the per-workspace secret at
146
+ // <workspaceRoot>/.evolver/workspace-id. The format (16-byte hex), the path,
147
+ // the 0600 mode, the O_EXCL|O_NOFOLLOW atomic create, and the symlink
148
+ // rejection all match paths.js exactly, so a workspace seeded by this fallback
149
+ // is transparently picked up by paths.getWorkspaceId() once the package is
150
+ // present, and vice-versa. Returns null on any read/write error (caller then
151
+ // falls back to legacy cwd-tag matching — no regression).
152
+ // Read <dir>/workspace-id with the same symlink guards paths.js'
153
+ // _readWorkspaceIdFromFs uses: reject a symlinked .evolver dir, reject a
154
+ // symlinked / non-regular id file, and require hex format. Returns the id, or
155
+ // null on any error / missing file. Used for BOTH the initial read and the
156
+ // EEXIST race re-read so a symlink swapped in between our lstat and openSync
157
+ // can never be followed (Bugbot PR #557).
158
+ function _readWsIdGuarded(dir, file) {
159
+ try {
160
+ const dirStat = fs.lstatSync(dir, { throwIfNoEntry: false });
161
+ if (dirStat && dirStat.isSymbolicLink()) return null;
162
+ const fileStat = fs.lstatSync(file, { throwIfNoEntry: false });
163
+ if (!fileStat) return null;
164
+ if (fileStat.isSymbolicLink() || !fileStat.isFile()) return null;
165
+ const raw = fs.readFileSync(file, 'utf8').trim();
166
+ return raw && /^[a-f0-9]{32,}$/i.test(raw) ? raw : null;
167
+ } catch { return null; }
168
+ }
169
+
170
+ function _fsWorkspaceId(projectDir) {
171
+ // Whole body is wrapped: the documented contract is "returns null on ANY
172
+ // read/write error" so the session-start/-end hooks degrade gracefully
173
+ // rather than crash. throwIfNoEntry:false only suppresses ENOENT; EACCES/EIO
174
+ // and friends still throw, so a bare lstat/mkdir here must not escape
175
+ // (Bugbot PR #557 round-2 — an unguarded lstat could crash the hook).
176
+ try {
177
+ const dir = path.join(_fsWorkspaceRoot(projectDir), '.evolver');
178
+ const file = path.join(dir, 'workspace-id');
179
+ // Read first, with symlink guards.
180
+ const existing = _readWsIdGuarded(dir, file);
181
+ if (existing) return existing;
182
+ // If the file exists but the guards rejected it (symlink / bad format),
183
+ // refuse rather than create over it.
184
+ if (fs.lstatSync(file, { throwIfNoEntry: false })) return null;
185
+ // Missing — create atomically. Refuse a symlinked .evolver dir (O_NOFOLLOW
186
+ // only guards the final component, not intermediate dirs).
187
+ const dirStat = fs.lstatSync(dir, { throwIfNoEntry: false });
188
+ if (dirStat && dirStat.isSymbolicLink()) return null;
189
+ fs.mkdirSync(dir, { recursive: true });
190
+ const payload = require('crypto').randomBytes(16).toString('hex');
191
+ const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL |
192
+ (fs.constants.O_NOFOLLOW || 0);
193
+ let fd;
194
+ try {
195
+ fd = fs.openSync(file, flags, 0o600);
196
+ } catch (e) {
197
+ // Lost a race — re-read WITH the same symlink guards (paths.js does the
198
+ // same). A bare readFileSync here would follow a symlink swapped in
199
+ // after our dir lstat (Bugbot PR #557).
200
+ if (e && e.code === 'EEXIST') return _readWsIdGuarded(dir, file);
201
+ return null; // ELOOP/EMLINK from O_NOFOLLOW hitting a symlink — refuse.
202
+ }
203
+ try { fs.writeSync(fd, payload + '\n', 0, 'utf8'); } finally { fs.closeSync(fd); }
204
+ try { fs.chmodSync(file, 0o600); } catch { /* best-effort */ }
205
+ return payload;
206
+ } catch { return null; }
207
+ }
208
+
209
+ // Resolve the current workspace id — the forge-resistant tag the session-end
210
+ // writer stamps on every memory-graph entry (`workspace_id`). This is the
211
+ // SINGLE source of that resolution: the session-end writer stamps it and the
212
+ // session-start reader scopes by it, so both call this one function. Keeping
213
+ // it here (rather than a copy per hook) is what guarantees reader and writer
214
+ // can never drift apart — if they resolved different ids, no entry would ever
215
+ // match the reader's filter and workspace scoping would silently break.
216
+ // Resolution order:
217
+ // 1. EVOLVER_WORKSPACE_ID env override
218
+ // 2. paths.getWorkspaceId() loaded from the resolved evolver root (this is
219
+ // the richer path — it can additionally back the secret with the OS
220
+ // keychain when @napi-rs/keyring is installed).
221
+ // 3. FS-only fallback for plugin-only installs where the evolver package is
222
+ // not reachable. Without this, plugin users got workspace_id=null and the
223
+ // forge-resistant scoping silently degraded to cwd-tag matching (found
224
+ // via real-Cursor end-to-end testing). The fallback writes the same
225
+ // secret file paths.js uses, so installing the package later is seamless.
226
+ // Still returns null if even the FS write fails — callers must then NOT filter
227
+ // (show everything), preserving prior behavior rather than hiding all memory.
228
+ function resolveWorkspaceId(evolverRoot, projectDir) {
229
+ if (process.env.EVOLVER_WORKSPACE_ID) return String(process.env.EVOLVER_WORKSPACE_ID);
230
+ const root = evolverRoot || findEvolverRoot();
231
+ if (root) {
232
+ try {
233
+ const paths = require(path.join(root, 'src', 'gep', 'paths.js'));
234
+ if (typeof paths.getWorkspaceId === 'function') return paths.getWorkspaceId();
235
+ } catch { /* paths.js unreachable — fall through to FS-only */ }
236
+ }
237
+ return _fsWorkspaceId(projectDir || resolveProjectDir());
238
+ }
239
+
83
240
  // Returns a path to the evolution memory graph, or a fallback location that
84
241
  // is guaranteed to be writable. Never returns null — when no evolver root is
85
242
  // available, we fall back to `~/.evolver/memory/evolution/memory_graph.jsonl`
@@ -111,4 +268,24 @@ function findMemoryGraph(evolverRoot) {
111
268
  return path.join(userDir, 'memory_graph.jsonl');
112
269
  }
113
270
 
114
- module.exports = { findEvolverRoot, findMemoryGraph };
271
+ // Is `dir` inside a git work tree? Cheap, no-shell `git rev-parse`. Returns
272
+ // false on any error (git missing, not a repo, timeout) and never throws — the
273
+ // session-start hook uses this only to decide whether to surface a one-line
274
+ // "evolver needs a git workspace" notice, so a false negative just suppresses
275
+ // the notice rather than breaking anything.
276
+ function isGitWorkspace(dir) {
277
+ try {
278
+ const res = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
279
+ cwd: dir,
280
+ encoding: 'utf8',
281
+ timeout: 5000,
282
+ stdio: ['ignore', 'pipe', 'pipe'],
283
+ shell: false,
284
+ });
285
+ return res.status === 0 && typeof res.stdout === 'string' && res.stdout.trim() === 'true';
286
+ } catch {
287
+ return false;
288
+ }
289
+ }
290
+
291
+ module.exports = { findEvolverRoot, findMemoryGraph, resolveProjectDir, resolveWorkspaceId, isGitWorkspace };
@@ -13,27 +13,16 @@ const { spawnSync } = require('child_process');
13
13
  // on large repos). See GHSA reports / issue #451.
14
14
  const MAX_EXEC_BUFFER = 10 * 1024 * 1024;
15
15
 
16
- const { findEvolverRoot, findMemoryGraph } = require('./_runtimePaths');
16
+ const { findEvolverRoot, findMemoryGraph, resolveProjectDir, resolveWorkspaceId } = require('./_runtimePaths');
17
17
 
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
- }
18
+ // Workspace-id resolution is shared with the session-start reader via
19
+ // _runtimePaths.resolveWorkspaceId(). Reader and writer MUST resolve the SAME
20
+ // id or workspace scoping silently breaks (no entry would ever match the
21
+ // reader's filter), so this logic lives in exactly one place instead of being
22
+ // duplicated here. The shared resolver mirrors src/gep/paths.js#getWorkspaceId()
23
+ // loaded from the evolver root, with an EVOLVER_WORKSPACE_ID env override
24
+ // consistent with the review-time reader in src/evolve/pipeline/collect.js
25
+ // (Bugbot PR #109 round-1 MEDIUM; reader/writer drift flagged on PR #555).
37
26
 
38
27
  function runGit(args, cwd) {
39
28
  // Argv-array form, no shell. Avoids POSIX `2>/dev/null` redirects that
@@ -55,7 +44,9 @@ function runGit(args, cwd) {
55
44
  }
56
45
 
57
46
  function getGitDiffStats() {
58
- const cwd = process.cwd();
47
+ // Use the host-provided workspace root, not process.cwd(): Cursor runs some
48
+ // hook events with cwd set to the plugin dir, where `git diff` finds nothing.
49
+ const cwd = resolveProjectDir();
59
50
  // Distinguish "git failed (no HEAD~1, etc.)" from "git succeeded with
60
51
  // empty output (e.g. empty merge)". The previous `||` chain treated
61
52
  // both as falsy and fell through to the working-tree diff, which can
@@ -67,11 +58,16 @@ function getGitDiffStats() {
67
58
  const filesChanged = (stat.match(/\d+ files? changed/) || ['0'])[0];
68
59
  const insertions = (stat.match(/(\d+) insertions?/) || [null, '0'])[1];
69
60
  const deletions = (stat.match(/(\d+) deletions?/) || [null, '0'])[1];
61
+ // Distinguish "no git repo here" from "repo with no changes" purely for the
62
+ // skip-log message — the diff commands above can't tell the two apart (both
63
+ // yield empty output). A single cheap rev-parse settles it.
64
+ const isRepo = runGit(['rev-parse', '--is-inside-work-tree'], cwd).out === 'true';
70
65
  return {
71
66
  stat,
72
67
  summary: `${filesChanged}, +${insertions}/-${deletions}`,
73
68
  diffSnippet: diffContent.slice(0, 2000),
74
69
  hasChanges: stat.length > 0,
70
+ isRepo,
75
71
  };
76
72
  }
77
73
 
@@ -152,6 +148,10 @@ function recordToHub(outcome) {
152
148
 
153
149
  function recordToLocal(graphPath, outcome) {
154
150
  try {
151
+ // Resolve the project dir once so the cwd tag and the workspace_id secret
152
+ // share a single, consistent source (both must agree with the session-start
153
+ // reader's resolveProjectDir()-based scoping).
154
+ const projectDir = resolveProjectDir();
155
155
  const entry = {
156
156
  timestamp: new Date().toISOString(),
157
157
  gene_id: outcome.geneId || 'ad_hoc',
@@ -174,8 +174,17 @@ function recordToLocal(graphPath, outcome) {
174
174
  // .evolver/workspace-id file. cwd is retained as a backward-compat
175
175
  // tag so older entries written before this hardening still pass
176
176
  // the cwd check.
177
- cwd: process.cwd(),
178
- workspace_id: resolveWorkspaceIdForWriter(),
177
+ //
178
+ // Use resolveProjectDir() (NOT process.cwd()) so the cwd tag records the
179
+ // user's project, consistent with how the diff above is collected and
180
+ // with the session-start reader's cwd fallback. Under Cursor, cwd is the
181
+ // plugin install dir, so a raw process.cwd() tag would never match the
182
+ // reader's resolveProjectDir()-derived currentDir — silently hiding every
183
+ // cwd-only entry (Bugbot PR #555). collect.js only uses cwd as a legacy
184
+ // fallback (disabled once a workspace_id secret exists), so changing the
185
+ // tag's source — still a directory path — does not affect its scoping.
186
+ cwd: projectDir,
187
+ workspace_id: resolveWorkspaceId(undefined, projectDir),
179
188
  source: 'hook:session-end',
180
189
  };
181
190
  fs.appendFileSync(graphPath, JSON.stringify(entry) + '\n', 'utf8');
@@ -185,6 +194,24 @@ function recordToLocal(graphPath, outcome) {
185
194
  }
186
195
  }
187
196
 
197
+ // Append a single timestamped line to ~/.evolver/logs/evolution.log (or
198
+ // EVOLVER_HOOK_LOG_DIR). Best-effort: a log-write failure must never break the
199
+ // hook. Used both for recorded outcomes and for the "skipped, nothing to
200
+ // record" notices so a user can always see why a session did or did not
201
+ // produce an entry.
202
+ function appendEvolutionLog(line) {
203
+ try {
204
+ const logDir = process.env.EVOLVER_HOOK_LOG_DIR
205
+ || path.join(os.homedir(), '.evolver', 'logs');
206
+ fs.mkdirSync(logDir, { recursive: true });
207
+ fs.appendFileSync(
208
+ path.join(logDir, 'evolution.log'),
209
+ `${new Date().toISOString()} ${line}\n`,
210
+ 'utf8'
211
+ );
212
+ } catch { /* best-effort, never break the hook on log write */ }
213
+ }
214
+
188
215
  function main() {
189
216
  let inputData = '';
190
217
  let handled = false;
@@ -204,6 +231,18 @@ function main() {
204
231
  const diffInfo = getGitDiffStats();
205
232
 
206
233
  if (!diffInfo.hasChanges) {
234
+ // No git diff means no signal source — session-end derives the
235
+ // outcome (status/score/signals/summary) entirely from the diff, so
236
+ // there is nothing meaningful to record. This is expected in a
237
+ // non-git workspace or a repo with no changes this session. Rather
238
+ // than fabricate an empty outcome (which would pollute the memory
239
+ // graph), record nothing — but leave a log breadcrumb so the user
240
+ // can tell "evolver ran but had nothing to record" apart from
241
+ // "evolver never fired". (Previously this branch was fully silent.)
242
+ const reason = diffInfo.isRepo
243
+ ? 'no changes detected this session'
244
+ : 'not a git workspace';
245
+ appendEvolutionLog(`[Evolution] Session end: nothing recorded (${reason}).`);
207
246
  finish({});
208
247
  return;
209
248
  }
@@ -254,16 +293,7 @@ function main() {
254
293
  // The receipt is always appended to ~/.evolver/logs/evolution.log
255
294
  // so it is never silently lost; users can opt back in to the inline
256
295
  // 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 */ }
296
+ appendEvolutionLog(msg);
267
297
 
268
298
  finish(isCursorHost() ? {} : { systemMessage: msg });
269
299
  } catch (e) {
@@ -7,17 +7,76 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
9
 
10
- const { findEvolverRoot, findMemoryGraph } = require('./_runtimePaths');
10
+ const { findEvolverRoot, findMemoryGraph, resolveProjectDir, resolveWorkspaceId, isGitWorkspace } = require('./_runtimePaths');
11
11
  const { filterRelevantOutcomes } = require('./_memoryFiltering');
12
12
 
13
- function readLastN(filePath, n) {
13
+ // One-line notice shown (throttled) when the workspace is not a git repo.
14
+ // Evolver derives every outcome from the git diff, so in a non-git folder the
15
+ // session-end hook records nothing — silently, unless we say so here. We surface
16
+ // it in session-start's additionalContext (injected as opening context, which
17
+ // does NOT trigger an extra inference round, unlike a stop-hook systemMessage).
18
+ const NON_GIT_NOTICE =
19
+ '[Evolver] This folder is not a git repository, so evolution memory is inactive ' +
20
+ '(outcomes are derived from git diffs). Run `git init` here, or open a git project, ' +
21
+ 'to enable recall and recording.';
22
+ const NON_GIT_NOTICE_TTL_MS = 30 * 60 * 1000; // once per 30 min per folder
23
+
24
+ // Return up to `n` of the current workspace's most-recent entries, in
25
+ // chronological (oldest-first) order.
26
+ //
27
+ // Why scan from the end: a plain tail-N-then-filter read would let outcomes
28
+ // from other projects (which share the user-level fallback graph on npm-global
29
+ // installs) crowd this workspace's entries out of the window — we must scope
30
+ // to the workspace BEFORE trimming. But parsing the ENTIRE file to do that is
31
+ // wasteful: the graph can reach ~100 MB before rotation, and JSON-parsing every
32
+ // line on each session start is real CPU/memory cost (Bugbot PR #555 round-3).
33
+ //
34
+ // So we read the file (cheap; the previous readLastN read it whole too) but
35
+ // JSON-parse lines lazily from the newest end, keeping only workspace matches,
36
+ // and stop as soon as we have `n`. Parse count is bounded by where this
37
+ // workspace's n-th-most-recent entry sits, not by total file size.
38
+ function readRecentWorkspaceEntries(filePath, currentId, currentDir, n) {
39
+ let lines;
14
40
  try {
15
- const content = fs.readFileSync(filePath, 'utf8');
16
- const lines = content.trim().split('\n').filter(Boolean);
17
- return lines.slice(-n).map(line => {
18
- try { return JSON.parse(line); } catch { return null; }
19
- }).filter(Boolean);
41
+ lines = fs.readFileSync(filePath, 'utf8').trim().split('\n');
20
42
  } catch { return []; }
43
+ const out = [];
44
+ for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
45
+ const line = lines[i];
46
+ if (!line) continue;
47
+ let entry;
48
+ try { entry = JSON.parse(line); } catch { continue; }
49
+ if (belongsToWorkspace(entry, currentId, currentDir)) out.push(entry);
50
+ }
51
+ return out.reverse(); // newest-collected-first -> chronological
52
+ }
53
+
54
+ // Does this memory-graph entry belong to the current workspace?
55
+ //
56
+ // The session-end writer stamps two tags: `workspace_id` (forge-resistant,
57
+ // preferred) and `cwd` (backward-compat). We scope reads so that one project
58
+ // never sees another's outcomes through the shared user-level fallback graph
59
+ // (~/.evolver/memory/evolution/memory_graph.jsonl) — the cross-project
60
+ // disclosure / prompt-injection surface Bugbot flagged on the writer side
61
+ // (PR #105 round-2), which the reader never enforced until now.
62
+ //
63
+ // Rules, in order:
64
+ // - currentId known + entry.workspace_id present -> must match exactly.
65
+ // - currentId unknown OR entry has neither tag (pre-hardening / Hub-sourced
66
+ // entries) -> do NOT exclude; falling back to "show it" preserves prior
67
+ // behavior and avoids hiding all memory when ids can't be resolved.
68
+ // - As a softer fallback, when the entry has no workspace_id but does carry a
69
+ // cwd, match that against the current project dir.
70
+ function belongsToWorkspace(entry, currentId, currentDir) {
71
+ if (entry && typeof entry.workspace_id === 'string' && entry.workspace_id) {
72
+ if (currentId) return entry.workspace_id === currentId;
73
+ return true; // can't compare — don't hide it
74
+ }
75
+ if (entry && typeof entry.cwd === 'string' && entry.cwd) {
76
+ if (currentDir) return entry.cwd === currentDir;
77
+ return true;
78
+ }
79
+ return true; // untagged (legacy / Hub) — never excluded
21
80
  }
22
81
 
23
82
  function formatOutcome(entry) {
@@ -46,18 +105,14 @@ function getDedupStatePath() {
46
105
  return path.join(dir, 'session-start-state.json');
47
106
  }
48
107
 
49
- function shouldSkipInjection() {
50
- // Only apply dedup when explicitly enabled (set by Kiro adapter) OR when
51
- // we detect a per-prompt-firing platform via PROMPT_SUBMIT heuristic in
52
- // stdin. The stdin is drained in main(), so we rely on env flag here.
53
- const dedupEnabled = String(process.env.EVOLVER_SESSION_START_DEDUP || '').toLowerCase() === '1'
54
- || String(process.env.EVOLVER_SESSION_START_DEDUP || '').toLowerCase() === 'true';
55
- if (!dedupEnabled) return false;
56
-
57
- const ttlMs = Number(process.env.EVOLVER_SESSION_START_DEDUP_TTL_MS) || (30 * 60 * 1000);
58
- const key = process.cwd();
108
+ // TTL throttle keyed by an arbitrary string, persisted in session-start-state
109
+ // .json. Returns true if `key` fired within the last `ttlMs` (caller should
110
+ // suppress); otherwise records "now" for `key` and returns false. Best-effort:
111
+ // a state read/write failure just means no throttling (fail open). Shared by
112
+ // the Kiro per-prompt dedup and the non-git notice so both age out of the same
113
+ // file (entries older than 24h are pruned on write).
114
+ function throttled(key, ttlMs) {
59
115
  const statePath = getDedupStatePath();
60
-
61
116
  let state = {};
62
117
  try {
63
118
  if (fs.existsSync(statePath)) {
@@ -67,9 +122,7 @@ function shouldSkipInjection() {
67
122
 
68
123
  const now = Date.now();
69
124
  const last = state[key];
70
- if (typeof last === 'number' && now - last < ttlMs) {
71
- return true;
72
- }
125
+ if (typeof last === 'number' && now - last < ttlMs) return true;
73
126
 
74
127
  state[key] = now;
75
128
  try {
@@ -82,47 +135,78 @@ function shouldSkipInjection() {
82
135
  fs.writeFileSync(tmp, JSON.stringify(state), 'utf8');
83
136
  fs.renameSync(tmp, statePath);
84
137
  } catch { /* best-effort */ }
85
-
86
138
  return false;
87
139
  }
88
140
 
141
+ function shouldSkipInjection() {
142
+ // Only apply dedup when explicitly enabled (set by Kiro adapter) OR when
143
+ // we detect a per-prompt-firing platform via PROMPT_SUBMIT heuristic in
144
+ // stdin. The stdin is drained in main(), so we rely on env flag here.
145
+ const dedupEnabled = String(process.env.EVOLVER_SESSION_START_DEDUP || '').toLowerCase() === '1'
146
+ || String(process.env.EVOLVER_SESSION_START_DEDUP || '').toLowerCase() === 'true';
147
+ if (!dedupEnabled) return false;
148
+
149
+ const ttlMs = Number(process.env.EVOLVER_SESSION_START_DEDUP_TTL_MS) || (30 * 60 * 1000);
150
+ return throttled(process.cwd(), ttlMs);
151
+ }
152
+
89
153
  function main() {
90
154
  if (shouldSkipInjection()) {
91
155
  process.stdout.write(JSON.stringify({}));
92
156
  return;
93
157
  }
94
158
 
159
+ const currentDir = resolveProjectDir();
160
+
161
+ // Non-git notice: evolver records nothing in a non-git folder (outcomes come
162
+ // from git diffs), so tell the user — once per folder per TTL — instead of
163
+ // failing silently. Emitted regardless of whether any memory exists below.
164
+ const parts = [];
165
+ if (!isGitWorkspace(currentDir) && !throttled('nongit:' + currentDir, NON_GIT_NOTICE_TTL_MS)) {
166
+ parts.push(NON_GIT_NOTICE);
167
+ }
168
+
95
169
  const evolverRoot = findEvolverRoot();
96
170
  const graphPath = findMemoryGraph(evolverRoot);
97
171
 
98
- if (!graphPath) {
99
- process.stdout.write(JSON.stringify({}));
100
- return;
172
+ // Scope to the current workspace BEFORE trimming to the most-recent window,
173
+ // so other projects sharing the user-level fallback graph can't crowd this
174
+ // workspace's outcomes out of view. When the workspace id can't be resolved,
175
+ // belongsToWorkspace() falls back to "show it" — no regression vs. the old
176
+ // unscoped behavior.
177
+ if (graphPath) {
178
+ const currentId = resolveWorkspaceId(evolverRoot, currentDir);
179
+ const recent = readRecentWorkspaceEntries(graphPath, currentId, currentDir, 5);
180
+ const filtered = filterRelevantOutcomes(recent);
181
+ if (filtered.length > 0) {
182
+ const successCount = filtered.filter(e => e.outcome && e.outcome.status === 'success').length;
183
+ const failCount = filtered.filter(e => e.outcome && e.outcome.status === 'failed').length;
184
+ parts.push([
185
+ `[Evolution Memory] Recent ${filtered.length} outcomes (${successCount} success, ${failCount} failed):`,
186
+ ...filtered.map(formatOutcome),
187
+ '',
188
+ 'Use successful approaches. Avoid repeating failed patterns.',
189
+ ].join('\n'));
190
+ }
101
191
  }
102
192
 
103
- const entries = readLastN(graphPath, 5);
104
- const filtered = filterRelevantOutcomes(entries);
105
-
106
- if (filtered.length === 0) {
193
+ if (parts.length === 0) {
107
194
  process.stdout.write(JSON.stringify({}));
108
195
  return;
109
196
  }
110
197
 
111
- const successCount = filtered.filter(e => e.outcome && e.outcome.status === 'success').length;
112
- const failCount = filtered.filter(e => e.outcome && e.outcome.status === 'failed').length;
113
-
114
- const lines = filtered.map(formatOutcome);
115
- const summary = [
116
- `[Evolution Memory] Recent ${filtered.length} outcomes (${successCount} success, ${failCount} failed):`,
117
- ...lines,
118
- '',
119
- 'Use successful approaches. Avoid repeating failed patterns.',
120
- ].join('\n');
121
-
198
+ const out = parts.join('\n\n');
122
199
  process.stdout.write(JSON.stringify({
123
- agent_message: summary,
124
- additionalContext: summary,
200
+ agent_message: out,
201
+ additionalContext: out,
125
202
  }));
126
203
  }
127
204
 
128
- main();
205
+ // Run as a hook when invoked directly; expose pure helpers for unit tests when
206
+ // required as a module. Guarding on require.main keeps the direct-execution
207
+ // behavior (the hosts run `node evolver-session-start.js`) unchanged.
208
+ if (require.main === module) {
209
+ main();
210
+ } else {
211
+ module.exports = { belongsToWorkspace };
212
+ }