@evomap/evolver 1.87.2 → 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 (69) hide show
  1. package/README.ja-JP.md +1 -1
  2. package/README.ko-KR.md +1 -1
  3. package/README.md +9 -8
  4. package/README.zh-CN.md +9 -8
  5. package/package.json +1 -1
  6. package/scripts/build_binaries.js +31 -7
  7. package/src/adapters/scripts/_runtimePaths.js +178 -1
  8. package/src/adapters/scripts/evolver-session-end.js +63 -33
  9. package/src/adapters/scripts/evolver-session-start.js +127 -43
  10. package/src/atp/atpExecute.js +35 -8
  11. package/src/atp/autoBuyer.js +71 -16
  12. package/src/atp/autoDeliver.js +16 -0
  13. package/src/atp/cliAutobuyPrompt.js +8 -22
  14. package/src/atp/hubClient.js +42 -4
  15. package/src/evolve/guards.js +1 -1
  16. package/src/evolve/pipeline/collect.js +1 -1
  17. package/src/evolve/pipeline/dispatch.js +1 -1
  18. package/src/evolve/pipeline/enrich.js +1 -1
  19. package/src/evolve/pipeline/hub.js +1 -1
  20. package/src/evolve/pipeline/select.js +1 -1
  21. package/src/evolve/pipeline/signals.js +1 -1
  22. package/src/evolve/utils.js +1 -1
  23. package/src/evolve.js +1 -1
  24. package/src/gep/a2aProtocol.js +1 -1
  25. package/src/gep/assetStore.js +52 -5
  26. package/src/gep/candidateEval.js +1 -1
  27. package/src/gep/candidates.js +1 -1
  28. package/src/gep/contentHash.js +1 -1
  29. package/src/gep/crypto.js +1 -1
  30. package/src/gep/curriculum.js +1 -1
  31. package/src/gep/deviceId.js +1 -1
  32. package/src/gep/envFingerprint.js +1 -1
  33. package/src/gep/epigenetics.js +1 -1
  34. package/src/gep/explore.js +1 -1
  35. package/src/gep/hash.js +1 -1
  36. package/src/gep/hubFetch.js +1 -1
  37. package/src/gep/hubReview.js +1 -1
  38. package/src/gep/hubSearch.js +1 -1
  39. package/src/gep/hubVerify.js +1 -1
  40. package/src/gep/idleScheduler.js +155 -6
  41. package/src/gep/learningSignals.js +1 -1
  42. package/src/gep/memoryGraph.js +1 -1
  43. package/src/gep/memoryGraphAdapter.js +1 -1
  44. package/src/gep/mutation.js +1 -1
  45. package/src/gep/narrativeMemory.js +1 -1
  46. package/src/gep/openPRRegistry.js +1 -1
  47. package/src/gep/paths.js +6 -2
  48. package/src/gep/personality.js +1 -1
  49. package/src/gep/policyCheck.js +1 -1
  50. package/src/gep/prompt.js +1 -1
  51. package/src/gep/recallVerifier.js +1 -1
  52. package/src/gep/reflection.js +1 -1
  53. package/src/gep/sanitize.js +57 -3
  54. package/src/gep/selector.js +1 -1
  55. package/src/gep/selfPR.js +34 -1
  56. package/src/gep/skill2gep.js +108 -29
  57. package/src/gep/skillDistiller.js +1 -1
  58. package/src/gep/solidify.js +1 -1
  59. package/src/gep/strategy.js +1 -1
  60. package/src/gep/workspaceKeychain.js +1 -1
  61. package/src/proxy/lifecycle/manager.js +97 -37
  62. package/src/proxy/router/messages_route.js +25 -0
  63. package/src/proxy/sync/engine.js +68 -31
  64. package/assets/gep/candidates.jsonl +0 -1
  65. package/assets/gep/capsules.json +0 -4
  66. package/assets/gep/events.jsonl +0 -0
  67. package/assets/gep/failed_capsules.json +0 -4
  68. package/assets/gep/genes.json +0 -245
  69. package/assets/gep/genes.jsonl +0 -0
package/README.ja-JP.md CHANGED
@@ -32,7 +32,7 @@
32
32
  >
33
33
  > Evolver はこの結論を実装に落とし込んだオープンソースエンジンです。GEP プロトコルの下で、エージェントの経験を場当たり的なプロンプトやスキルドキュメントではなく、Gene と Capsule として符号化します。*なぜ* Evolver が長いスキルドキュメントではなく Gene にこだわるのか疑問に思ったことがあるなら、読むべきはこの論文です。
34
34
  >
35
- > 応用事例を見たい方へ:[OpenClaw x EvoMap:CritPt 評価レポート](https://evomap.ai/blog/openclaw-critpt-report) では、OpenClaw エージェントが CritPt Physics Solver 上の 5 バージョン(Beta → v2.2)にわたって、同じ Gene ベース進化ループによってスコアを 0.00% から 18.57% まで押し上げる全過程を、トークンコストの軌跡、遺伝子活性化マップ、そして推論が再利用可能な Gene に圧縮されるときに現れる「トークンが上昇してから下降する」シグネチャとともに詳述しています。
35
+ > 応用事例を見たい方へ:[OpenClaw x EvoMap:CritPt 評価レポート](https://evomap.ai/blog/openclaw-critpt-report) では、OpenClaw エージェントが CritPt Physics Solver 上の 5 バージョン(Beta → v2.2)にわたって、同じ Gene ベース進化ループによってスコアを 9.1% から 18.57% まで押し上げる全過程を、トークンコストの軌跡、遺伝子活性化マップ、そして推論が再利用可能な Gene に圧縮されるときに現れる「トークンが上昇してから下降する」シグネチャとともに詳述しています。
36
36
 
37
37
  ---
38
38
 
package/README.ko-KR.md CHANGED
@@ -32,7 +32,7 @@
32
32
  >
33
33
  > Evolver는 이 결과를 실제로 구현하는 오픈소스 엔진입니다. GEP 프로토콜 아래 에이전트의 경험을 임시 프롬프트나 스킬 문서가 아니라 Gene과 Capsule로 인코딩합니다. *왜* Evolver가 더 긴 스킬 문서 대신 Gene을 고집하는지 궁금했다면, 바로 이 논문을 읽어야 합니다.
34
34
  >
35
- > 적용 사례가 궁금하신가요? [OpenClaw x EvoMap: CritPt 평가 보고서](https://evomap.ai/blog/openclaw-critpt-report)는 동일한 Gene 기반 진화 루프가 OpenClaw 에이전트를 CritPt Physics Solver의 5개 버전(Beta → v2.2)에 걸쳐 0.00%에서 18.57%까지 끌어올리는 과정을, 전체 토큰 비용 궤적, 유전자 활성화 매핑, 그리고 추론이 재사용 가능한 Gene으로 압축될 때 나타나는 "토큰이 먼저 상승한 뒤 하강하는" 시그니처와 함께 단계별로 보여줍니다.
35
+ > 적용 사례가 궁금하신가요? [OpenClaw x EvoMap: CritPt 평가 보고서](https://evomap.ai/blog/openclaw-critpt-report)는 동일한 Gene 기반 진화 루프가 OpenClaw 에이전트를 CritPt Physics Solver의 5개 버전(Beta → v2.2)에 걸쳐 9.1%에서 18.57%까지 끌어올리는 과정을, 전체 토큰 비용 궤적, 유전자 활성화 매핑, 그리고 추론이 재사용 가능한 Gene으로 압축될 때 나타나는 "토큰이 먼저 상승한 뒤 하강하는" 시그니처와 함께 단계별로 보여줍니다.
36
36
 
37
37
  ---
38
38
 
package/README.md CHANGED
@@ -32,7 +32,7 @@
32
32
  >
33
33
  > Evolver is the open-source engine that puts this result into practice: it encodes agent experience as Genes and Capsules under the GEP protocol, not as ad hoc prompts or skill docs. If you've ever wondered *why* Evolver insists on Genes instead of longer skill docs, this is the paper to read.
34
34
  >
35
- > Want the applied version? [OpenClaw x EvoMap: CritPt Evaluation Report](https://evomap.ai/blog/openclaw-critpt-report) walks through how the same Gene-based evolution loop drives an OpenClaw agent from 0.00% to 18.57% on CritPt Physics Solver across five versions (Beta -> v2.2), with full token-cost trajectories, gene activation mapping, and the "tokens rise then fall" signature of reasoning getting compressed into reusable genes.
35
+ > Want the applied version? [OpenClaw x EvoMap: CritPt Evaluation Report](https://evomap.ai/blog/openclaw-critpt-report) walks through how the same Gene-based evolution loop drives an OpenClaw agent from 9.1% to 18.57% on CritPt Physics Solver across five versions (Beta -> v2.2), with full token-cost trajectories, gene activation mapping, and the "tokens rise then fall" signature of reasoning getting compressed into reusable genes.
36
36
 
37
37
  ---
38
38
 
@@ -184,7 +184,7 @@ Every `evolver <flag>` invocation in the rest of this README maps 1:1 to `node i
184
184
  **Evolver is a prompt generator, not a code patcher.** Each evolution cycle:
185
185
 
186
186
  1. Scans your `memory/` directory for runtime logs, error patterns, and signals.
187
- 2. Selects the best-matching [Gene or Capsule](https://evomap.ai/wiki) from `assets/gep/`.
187
+ 2. Selects the best-matching [Gene or Capsule](https://evomap.ai/wiki) from the local GEP asset store.
188
188
  3. Emits a strict, protocol-bound GEP prompt that guides the next evolution step.
189
189
  4. Records an auditable [EvolutionEvent](https://evomap.ai/wiki) for traceability.
190
190
 
@@ -377,16 +377,17 @@ The [evomap.ai](https://evomap.ai) dashboard has a "Worker" toggle on the node d
377
377
 
378
378
  This repo includes a protocol-constrained prompt mode based on [GEP (Genome Evolution Protocol)](https://evomap.ai/wiki).
379
379
 
380
- - **Structured assets** live in `assets/gep/`:
381
- - `assets/gep/genes.json`
382
- - `assets/gep/capsules.json`
383
- - `assets/gep/events.jsonl`
380
+ - **Structured runtime assets** live in `<workspace>/.evolver/gep/` by default:
381
+ - `<workspace>/.evolver/gep/genes.json`
382
+ - `<workspace>/.evolver/gep/capsules.json`
383
+ - `<workspace>/.evolver/gep/events.jsonl`
384
+ - Set `GEP_ASSETS_DIR` to place the runtime asset store elsewhere.
384
385
  - **Selector** logic uses extracted signals to prefer existing Genes/Capsules and emits a JSON selector decision in the prompt.
385
386
  - **Constraints**: Only the DNA emoji is allowed in documentation; all other emoji are disallowed.
386
387
 
387
388
  ### Your local asset store is never overwritten by upgrades
388
389
 
389
- `assets/gep/genes.json`, `assets/gep/capsules.json`, and `assets/gep/events.jsonl` are owned by your runtime. Starting with 1.78.3, the npm tarball no longer contains these files, so `npm i -g @evomap/evolver` (or `git pull` of the public repo) never clobbers your accumulated Genes, Capsules, or EvolutionEvents. New installs still receive the curated starter Genes through `assets/gep/genes.seed.json`, which is applied only when `genes.json` is absent.
390
+ `<workspace>/.evolver/gep/genes.json`, `<workspace>/.evolver/gep/capsules.json`, and `<workspace>/.evolver/gep/events.jsonl` are owned by your runtime and ignored by git. `assets/gep/` is reserved for bundled starter assets. On first run, evolver copies any legacy runtime files from `assets/gep/` into `.evolver/gep/` without deleting the originals, then seeds `genes.json` from the bundled starter genes only when no local `genes.json` exists.
390
391
 
391
392
  If you ran an older evolver version that wiped your local assets, pull back everything you Promoted or published to the Hub with a single command:
392
393
 
@@ -396,7 +397,7 @@ A2A_HUB_URL=https://evomap.ai evolver sync --scope=all --export=backup.gepx
396
397
 
397
398
  This hits `/a2a/assets/purchased` (Promoted-to-you plus self-purchased) and `/a2a/assets/published-by-me` (your own drafts and published assets), re-materializes the full payloads into `genes.json` / `capsules.json`, and packs a portable `.gepx` bundle. Previously-purchased payloads re-fetch at zero cost.
398
399
 
399
- Purely local assets that were never uploaded to the Hub have no remote copy -- recover them from your git history (for example `git show <old_tag>:assets/gep/genes.json > restored.json`) or from disk snapshots.
400
+ Purely local assets that were never uploaded to the Hub have no remote copy -- recover them from `.evolver/gep/`, from an older `assets/gep/` checkout, or from disk snapshots.
400
401
 
401
402
  ## Configuration & Decoupling
402
403
 
package/README.zh-CN.md CHANGED
@@ -32,7 +32,7 @@
32
32
  >
33
33
  > Evolver 正是把这一结论落地的开源引擎:它基于 GEP 协议,把 Agent 的经验沉淀为 Gene 与 Capsule,而不是散落的 prompt 或技能文档。如果你想知道 *为什么* Evolver 坚持使用 Gene 而不是更长的 skill 文档,这就是那篇该读的论文。
34
34
  >
35
- > 想看应用落地的样本?[OpenClaw x EvoMap:CritPt 评测报告](https://evomap.ai/blog/openclaw-critpt-report) 以 OpenClaw Agent 在 CritPt Physics Solver 上的五个版本演进(Beta → v2.2)为例,完整拆解了同一套 Gene 进化闭环如何把得分从 0.00% 推到 18.57%,并给出 token 成本轨迹、基因激活映射,以及推理被压缩成可复用基因后所呈现的「token 先升后降」特征。
35
+ > 想看应用落地的样本?[OpenClaw x EvoMap:CritPt 评测报告](https://evomap.ai/blog/openclaw-critpt-report) 以 OpenClaw Agent 在 CritPt Physics Solver 上的五个版本演进(Beta → v2.2)为例,完整拆解了同一套 Gene 进化闭环如何把得分从 9.1% 推到 18.57%,并给出 token 成本轨迹、基因激活映射,以及推理被压缩成可复用基因后所呈现的「token 先升后降」特征。
36
36
 
37
37
  ---
38
38
 
@@ -159,7 +159,7 @@ evolver --loop
159
159
  **Evolver 是一个提示词生成器,不是代码修改器。** 每个进化周期:
160
160
 
161
161
  1. 扫描 `memory/` 目录中的运行日志、错误模式和信号。
162
- 2. `assets/gep/` 中选择最匹配的 [Gene 或 Capsule](https://evomap.ai/wiki)。
162
+ 2. 从本地 GEP 资产库中选择最匹配的 [Gene 或 Capsule](https://evomap.ai/wiki)。
163
163
  3. 输出一份严格的、受协议约束的 GEP 提示词来引导下一步进化。
164
164
  4. 记录可审计的 [EvolutionEvent](https://evomap.ai/wiki) 以便追溯。
165
165
 
@@ -351,16 +351,17 @@ WORKER_ENABLED=1 WORKER_DOMAINS=repair,harden WORKER_MAX_LOAD=3 evolver --loop
351
351
 
352
352
  本仓库内置基于 [GEP(基因组进化协议)](https://evomap.ai/wiki)的协议受限提示词模式。
353
353
 
354
- - **结构化资产目录**:`assets/gep/`
355
- - `assets/gep/genes.json`
356
- - `assets/gep/capsules.json`
357
- - `assets/gep/events.jsonl`
354
+ - **结构化运行时资产目录**:默认位于 `<workspace>/.evolver/gep/`
355
+ - `<workspace>/.evolver/gep/genes.json`
356
+ - `<workspace>/.evolver/gep/capsules.json`
357
+ - `<workspace>/.evolver/gep/events.jsonl`
358
+ - 可通过 `GEP_ASSETS_DIR` 把运行时资产库放到其他位置。
358
359
  - **Selector 选择器**:根据日志提取 signals,优先复用已有 Gene/Capsule,并在提示词中输出可审计的 Selector 决策 JSON。
359
360
  - **约束**:除 🧬 外,禁止使用其他 emoji。
360
361
 
361
362
  ### 升级不再覆盖你的本地资产库
362
363
 
363
- `assets/gep/genes.json`、`assets/gep/capsules.json`、`assets/gep/events.jsonl` 属于你本地运行时。从 1.78.3 起,npm 发行包不再包含这三个文件,`npm i -g @evomap/evolver`(或公共仓库的 `git pull`)不会再覆盖你累积的 Gene、Capsule 和 EvolutionEvent。新装用户依然会通过 `assets/gep/genes.seed.json` 拿到引擎维护的 starter Gene —— 只有在本地 `genes.json` 不存在时才会应用一次。
364
+ `<workspace>/.evolver/gep/genes.json`、`<workspace>/.evolver/gep/capsules.json`、`<workspace>/.evolver/gep/events.jsonl` 属于你本地运行时,并被 git 忽略。`assets/gep/` 保留给随包发布的 starter 资产。首次运行时,evolver 会把旧版遗留在 `assets/gep/` 的运行时文件复制到 `.evolver/gep/`,不会删除原文件;只有在本地 `genes.json` 不存在时,才会从随包 starter Gene 初始化。
364
365
 
365
366
  如果你之前用老版本被覆盖过,现在可以一键把所有被 Promoted 给你、以及你自己上传到 Hub 的资产拉回来:
366
367
 
@@ -370,7 +371,7 @@ A2A_HUB_URL=https://evomap.ai evolver sync --scope=all --export=backup.gepx
370
371
 
371
372
  它会去 `/a2a/assets/purchased`(被 Promoted 给你 + 自购)和 `/a2a/assets/published-by-me`(你自己发布的,含 draft)拉回完整 payload,直接回写 `genes.json` / `capsules.json`,并顺便打成 `.gepx` 整包备份。已购买过的 payload 这次重新拉取不收费。
372
373
 
373
- 纯本地、从未上传过的资产 Hub 没有副本,只能从 git 历史恢复(例如 `git show <老tag>:assets/gep/genes.json > restored.json`)或从磁盘快照找回。
374
+ 纯本地、从未上传过的资产 Hub 没有副本,只能从 `.evolver/gep/`、旧版 `assets/gep/` checkout 或磁盘快照找回。
374
375
 
375
376
  ## 配置与解耦
376
377
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evomap/evolver",
3
- "version": "1.87.2",
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": {
@@ -192,7 +192,14 @@ step('Stage 1 — bun bundle (resolve require tree to one file)');
192
192
  ensureDir(STAGE_DIR);
193
193
  const BUNDLED_JS = path.join(STAGE_DIR, 'bundled.js');
194
194
 
195
- run('bun', ['build', ENTRY, '--target=node', `--outfile=${BUNDLED_JS}`]);
195
+ // `--external '@napi-rs/keyring'`: keyring is an optional dep loaded via
196
+ // dynamic require() in workspace-id; bun otherwise tries to bundle the
197
+ // platform-specific `.node` file as a second output asset, which makes
198
+ // `bun build … --outfile=…` fail with "cannot write multiple output files
199
+ // without an output directory". Treating it as external preserves the
200
+ // existing optional-fallback behaviour (require throws → FS path used) in
201
+ // the standalone binaries.
202
+ run('bun', ['build', ENTRY, '--target=node', `--outfile=${BUNDLED_JS}`, '--external', '@napi-rs/keyring']);
196
203
 
197
204
  const bundleSize = OPTS.dryRun ? 0 : fs.statSync(BUNDLED_JS).size;
198
205
  console.log(` bundled.js: ${(bundleSize / 1024 / 1024).toFixed(2)} MB`);
@@ -266,13 +273,30 @@ if (!OPTS.skipObfuscate) {
266
273
  const obfSecs = ((Date.now() - t0) / 1000).toFixed(1);
267
274
 
268
275
  const check = spawnSync('node', ['--check', OBF_JS], { encoding: 'utf8' });
269
- if (check.status === 0) {
270
- console.log(` obfuscation: ${obfSecs}s, output ${(obfSize / 1024 / 1024).toFixed(2)} MB (attempt ${attempt}/${MAX_OBF_ATTEMPTS}, seed=0x${usedSeed.toString(16)})`);
271
- succeeded = true;
272
- break;
276
+ if (check.status !== 0) {
277
+ lastValidationErr = (check.stderr || check.stdout || '').split('\n').slice(0, 3).join(' | ');
278
+ console.warn(` attempt ${attempt}/${MAX_OBF_ATTEMPTS}: obfuscator output failed node --check (${lastValidationErr.slice(0, 200)}); retrying with perturbed seed...`);
279
+ continue;
273
280
  }
274
- lastValidationErr = (check.stderr || check.stdout || '').split('\n').slice(0, 3).join(' | ');
275
- console.warn(` attempt ${attempt}/${MAX_OBF_ATTEMPTS}: obfuscator output failed node --check (${lastValidationErr.slice(0, 200)}); retrying with perturbed seed...`);
281
+ // Second gate: bun's compile-time parser is stricter than node's.
282
+ // 1.87.x (post `@napi-rs/keyring` dep) revealed that ~5% of obfuscator
283
+ // outputs that pass `node --check` still trip bun with errors like
284
+ // `Expected "in" but found ","`. Probe with a cheap bundle-only call
285
+ // (no --compile, native target) to fail fast and feed back into the
286
+ // seed-perturbation loop instead of dying in stage 3.
287
+ const bunProbe = spawnSync('bun', [
288
+ 'build', OBF_JS,
289
+ '--target=bun',
290
+ `--outfile=${path.join(STAGE_DIR, 'bundled.obf.bunprobe.js')}`,
291
+ ], { encoding: 'utf8' });
292
+ if (bunProbe.status !== 0) {
293
+ lastValidationErr = (bunProbe.stderr || bunProbe.stdout || '').split('\n').slice(0, 3).join(' | ');
294
+ console.warn(` attempt ${attempt}/${MAX_OBF_ATTEMPTS}: obfuscator output rejected by bun parser (${lastValidationErr.slice(0, 200)}); retrying with perturbed seed...`);
295
+ continue;
296
+ }
297
+ console.log(` obfuscation: ${obfSecs}s, output ${(obfSize / 1024 / 1024).toFixed(2)} MB (attempt ${attempt}/${MAX_OBF_ATTEMPTS}, seed=0x${usedSeed.toString(16)})`);
298
+ succeeded = true;
299
+ break;
276
300
  }
277
301
  if (!succeeded) {
278
302
  console.error(` ERROR: javascript-obfuscator produced syntactically invalid output in ${MAX_OBF_ATTEMPTS} attempts.`);
@@ -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) {