From 940210dae69daef91221c2c1dfdc7a7ce95f6dd8 Mon Sep 17 00:00:00 2001 From: Vinod Muthusamy Date: Mon, 4 May 2026 13:28:54 -0500 Subject: [PATCH 1/2] fix(learn): resolve saved-trajectory path via shared get_trajectories_dir The learn Stop hook hardcoded `.evolve/trajectories/claude-transcript_.jsonl` while the save-trajectory Stop hook resolved the same path from env. When EVOLVE_DIR was set the two hooks disagreed and the learn skill read a non-existent file, silently producing zero guidelines. Add get_trajectories_dir() to lib/entity_io.py that returns `get_evolve_dir() / "trajectories"` (absolute). Both Stop hooks now share this helper; learn's on_stop emits the resolved absolute path in its reason field. Update SKILL.md so the skill no longer assumes a relative path. Add platform-integration tests covering EVOLVE_DIR-set and default scenarios plus a parity check between the two hooks. Fixes #246 --- .../plugins/evolve-lite/lib/entity_io.py | 11 ++ .../plugins/evolve-lite/skills/learn/SKILL.md | 2 +- .../skills/learn/scripts/on_stop.py | 5 +- .../skills/save-trajectory/scripts/on_stop.py | 17 +- .../test_stop_hooks_path_resolution.py | 152 ++++++++++++++++++ 5 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 tests/platform_integrations/test_stop_hooks_path_resolution.py diff --git a/platform-integrations/claude/plugins/evolve-lite/lib/entity_io.py b/platform-integrations/claude/plugins/evolve-lite/lib/entity_io.py index b8e0eefa..a73e2e1f 100644 --- a/platform-integrations/claude/plugins/evolve-lite/lib/entity_io.py +++ b/platform-integrations/claude/plugins/evolve-lite/lib/entity_io.py @@ -97,6 +97,17 @@ def get_default_entities_dir(): return base.resolve() +def get_trajectories_dir(): + """Return (and create) the trajectories directory as an absolute Path. + + Uses :func:`get_evolve_dir` for the base so trajectories land alongside + entities under the same ``EVOLVE_DIR`` / ``.evolve`` root. + """ + base = get_evolve_dir() / "trajectories" + base.mkdir(parents=True, exist_ok=True, mode=0o700) + return base.resolve() + + # --------------------------------------------------------------------------- # Slugify / filename helpers # --------------------------------------------------------------------------- diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/learn/SKILL.md b/platform-integrations/claude/plugins/evolve-lite/skills/learn/SKILL.md index 0d90a265..93d6f85e 100644 --- a/platform-integrations/claude/plugins/evolve-lite/skills/learn/SKILL.md +++ b/platform-integrations/claude/plugins/evolve-lite/skills/learn/SKILL.md @@ -29,7 +29,7 @@ This skill analyzes the current conversation to extract guidelines that **correc This skill runs in a forked context with no access to the parent conversation. The stop-hook message (produced by `on_stop.py`) contains one literal marker: -- `The saved trajectory path is: ` — a copy of the session transcript saved inside the project tree at `.evolve/trajectories/claude-transcript_.jsonl`. Take everything after the colon, strip surrounding whitespace and quotes, and use the result as `saved_trajectory_path`. You will also attach this exact path to each entity's `trajectory` field in Step 4. +- `The saved trajectory path is: ` — a copy of the session transcript written by the save-trajectory Stop hook. The path is absolute and resolves under `$EVOLVE_DIR/trajectories/` (or the project's `.evolve/trajectories/` when `EVOLVE_DIR` is unset), with filename `claude-transcript_.jsonl`. Take everything after the colon, strip surrounding whitespace and quotes, and use the result as `saved_trajectory_path`. You will also attach this exact path to each entity's `trajectory` field in Step 4. **Read this file with the `Read` tool — do NOT shell out.** `Read` pages large files natively (use its `offset` / `limit` parameters if needed). Do not use `cat`, `head`, `wc`, `find`, or `python3 -c` loops on the transcript — those trigger a permission prompt for every invocation and are unnecessary. diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/learn/scripts/on_stop.py b/platform-integrations/claude/plugins/evolve-lite/skills/learn/scripts/on_stop.py index d26afbcb..58e250e8 100644 --- a/platform-integrations/claude/plugins/evolve-lite/skills/learn/scripts/on_stop.py +++ b/platform-integrations/claude/plugins/evolve-lite/skills/learn/scripts/on_stop.py @@ -5,6 +5,9 @@ import sys from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "lib")) +from entity_io import get_trajectories_dir # noqa: E402 + def main(): try: @@ -20,7 +23,7 @@ def main(): if transcript_path: session_id = Path(transcript_path).stem.removeprefix("claude-transcript_") if session_id: - saved_trajectory = f".evolve/trajectories/claude-transcript_{session_id}.jsonl" + saved_trajectory = str(get_trajectories_dir() / f"claude-transcript_{session_id}.jsonl") reason += f" The saved trajectory path is: {saved_trajectory}" print( diff --git a/platform-integrations/claude/plugins/evolve-lite/skills/save-trajectory/scripts/on_stop.py b/platform-integrations/claude/plugins/evolve-lite/skills/save-trajectory/scripts/on_stop.py index 81c3400e..0dcc4ad3 100644 --- a/platform-integrations/claude/plugins/evolve-lite/skills/save-trajectory/scripts/on_stop.py +++ b/platform-integrations/claude/plugins/evolve-lite/skills/save-trajectory/scripts/on_stop.py @@ -10,6 +10,9 @@ import tempfile from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "lib")) +from entity_io import get_trajectories_dir # noqa: E402 + _log_file = None @@ -38,20 +41,6 @@ def log(message): pass -def get_trajectories_dir(): - evolve_dir = os.environ.get("EVOLVE_DIR") - if evolve_dir: - base = Path(evolve_dir) / "trajectories" - else: - project_root = os.environ.get("CLAUDE_PROJECT_ROOT", "") - if project_root: - base = Path(project_root) / ".evolve" / "trajectories" - else: - base = Path(".evolve") / "trajectories" - base.mkdir(parents=True, exist_ok=True, mode=0o700) - return base.resolve() - - def main(): try: input_data = json.load(sys.stdin) diff --git a/tests/platform_integrations/test_stop_hooks_path_resolution.py b/tests/platform_integrations/test_stop_hooks_path_resolution.py new file mode 100644 index 00000000..66fe501b --- /dev/null +++ b/tests/platform_integrations/test_stop_hooks_path_resolution.py @@ -0,0 +1,152 @@ +"""Tests that both Stop hooks agree on where the saved transcript lives. + +Issue #246: ``learn/scripts/on_stop.py`` used to hardcode +``.evolve/trajectories/...`` while ``save-trajectory/scripts/on_stop.py`` +resolved the path from env. When ``EVOLVE_DIR`` was set the two hooks +disagreed and the learn skill read a non-existent file. + +These tests invoke each hook as a subprocess with a synthetic stdin payload +and assert the path emitted (save-trajectory prints it on stdout; learn +embeds it in the ``reason`` field) matches the shared resolver for the +``EVOLVE_DIR``-set and default scenarios. +""" + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.platform_integrations + +_REPO_ROOT = Path(__file__).parent.parent.parent +_PLUGIN_ROOT = _REPO_ROOT / "platform-integrations/claude/plugins/evolve-lite" +LEARN_ON_STOP = _PLUGIN_ROOT / "skills/learn/scripts/on_stop.py" +SAVE_TRAJ_ON_STOP = _PLUGIN_ROOT / "skills/save-trajectory/scripts/on_stop.py" + +_SESSION_ID = "abc-123" +_EXPECTED_FILENAME = f"claude-transcript_{_SESSION_ID}.jsonl" + + +def _run_hook(script, cwd, env_overrides, transcript_path): + """Run a Stop hook with a synthetic stdin payload and return CompletedProcess.""" + env = {k: v for k, v in os.environ.items() if k not in {"EVOLVE_DIR", "CLAUDE_PROJECT_ROOT"}} + env.update(env_overrides) + payload = {"transcript_path": str(transcript_path), "stop_hook_active": False} + return subprocess.run( + [sys.executable, str(script)], + input=json.dumps(payload), + capture_output=True, + text=True, + cwd=str(cwd), + env=env, + check=True, + ) + + +def _learn_reason_path(stdout): + """Extract the path from the learn hook's `reason` field.""" + data = json.loads(stdout) + reason = data["reason"] + marker = "The saved trajectory path is: " + assert marker in reason, f"marker missing in reason: {reason!r}" + return reason.split(marker, 1)[1].strip() + + +def _save_trajectory_path(stdout): + """save-trajectory prints `Trajectory saved: ` on stdout.""" + line = stdout.strip().splitlines()[-1] + prefix = "Trajectory saved: " + assert line.startswith(prefix), f"unexpected stdout: {stdout!r}" + return line[len(prefix):] + + +@pytest.fixture +def fake_transcript(tmp_path): + """Create a fake live transcript file matching Claude Code's layout.""" + src = tmp_path / "projects" / "fake-project" / f"{_SESSION_ID}.jsonl" + src.parent.mkdir(parents=True) + src.write_text('{"type":"assistant","content":"hi"}\n') + return src + + +# --------------------------------------------------------------------------- +# learn/on_stop.py — emits the resolved path in its `reason` field +# --------------------------------------------------------------------------- + + +def test_learn_uses_evolve_dir(tmp_path, fake_transcript): + custom = tmp_path / "my-evolve" + result = _run_hook( + LEARN_ON_STOP, + cwd=tmp_path, + env_overrides={"EVOLVE_DIR": str(custom)}, + transcript_path=fake_transcript, + ) + path = _learn_reason_path(result.stdout) + assert path == str((custom / "trajectories" / _EXPECTED_FILENAME).resolve()) + + +def test_learn_defaults_to_cwd_evolve(tmp_path, fake_transcript): + result = _run_hook( + LEARN_ON_STOP, + cwd=tmp_path, + env_overrides={}, + transcript_path=fake_transcript, + ) + path = _learn_reason_path(result.stdout) + expected = (tmp_path / ".evolve" / "trajectories" / _EXPECTED_FILENAME).resolve() + assert path == str(expected) + + +# --------------------------------------------------------------------------- +# save-trajectory/on_stop.py — still resolves to the same locations +# --------------------------------------------------------------------------- + + +def test_save_trajectory_uses_evolve_dir(tmp_path, fake_transcript): + custom = tmp_path / "my-evolve" + result = _run_hook( + SAVE_TRAJ_ON_STOP, + cwd=tmp_path, + env_overrides={"EVOLVE_DIR": str(custom)}, + transcript_path=fake_transcript, + ) + written = Path(_save_trajectory_path(result.stdout)) + assert written == (custom / "trajectories" / _EXPECTED_FILENAME).resolve() + assert written.is_file() + + +def test_save_trajectory_defaults_to_cwd_evolve(tmp_path, fake_transcript): + result = _run_hook( + SAVE_TRAJ_ON_STOP, + cwd=tmp_path, + env_overrides={}, + transcript_path=fake_transcript, + ) + written = Path(_save_trajectory_path(result.stdout)) + assert written == (tmp_path / ".evolve" / "trajectories" / _EXPECTED_FILENAME).resolve() + assert written.is_file() + + +# --------------------------------------------------------------------------- +# Parity — the two hooks must agree on the same path for the same session +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "env_fn", + [ + pytest.param(lambda tmp: {"EVOLVE_DIR": str(tmp / "my-evolve")}, id="evolve-dir"), + pytest.param(lambda tmp: {}, id="default"), + ], +) +def test_hooks_agree_on_path(tmp_path, fake_transcript, env_fn): + env = env_fn(tmp_path) + save_result = _run_hook(SAVE_TRAJ_ON_STOP, tmp_path, env, fake_transcript) + learn_result = _run_hook(LEARN_ON_STOP, tmp_path, env, fake_transcript) + written = _save_trajectory_path(save_result.stdout) + announced = _learn_reason_path(learn_result.stdout) + assert written == announced From 759b548c68473253332fb08d44cb8613712ac1ee Mon Sep 17 00:00:00 2001 From: Vinod Muthusamy Date: Mon, 4 May 2026 13:44:27 -0500 Subject: [PATCH 2/2] fix(tests): satisfy ruff format on slice whitespace Fixes failing CI check: check-formatting (3.12) --- tests/platform_integrations/test_stop_hooks_path_resolution.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/platform_integrations/test_stop_hooks_path_resolution.py b/tests/platform_integrations/test_stop_hooks_path_resolution.py index 66fe501b..9b6cb35d 100644 --- a/tests/platform_integrations/test_stop_hooks_path_resolution.py +++ b/tests/platform_integrations/test_stop_hooks_path_resolution.py @@ -60,7 +60,7 @@ def _save_trajectory_path(stdout): line = stdout.strip().splitlines()[-1] prefix = "Trajectory saved: " assert line.startswith(prefix), f"unexpected stdout: {stdout!r}" - return line[len(prefix):] + return line[len(prefix) :] @pytest.fixture