From 7de18979e5061214ae183a72e9ab84b26da78988 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Sun, 12 Jul 2026 23:25:40 -0700 Subject: [PATCH] fix(prompt): name ~/.clawcodex in # Environment so the model finds session history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When asked to check previous session history, the agent guessed ~/.claude (the real Claude Code harness's dir) or a mangled ~/.Claude Code/sessions and wasted turns — the .claude→.clawcodex rebrand broke the model's trained-in prior and the # Environment block never named clawcodex's own data root. Add a constant line to the env section (both _build_env_section, the live agent-server/headless/REPL path, and the simplified _compute_env_info) naming the clawcodex data directory and steering off ~/.claude. Anchor on Path.home()/".clawcodex" (NOT get_user_config_dir()): sessions/ and transcripts/ are hardcoded there and deliberately ignore $CLAWCODEX_CONFIG_DIR, so this is their invariant location in both default and override configs — anchoring on the override root would authoritatively misdirect the model whenever it's set. The line is constant per process, so it stays byte-stable on the REQUEST cache prefix (no caching regression). Tests: override-invariance, a drift guard tying the named root to the real SESSIONS_DIR + transcripts sources, an end-to-end live-prompt assertion, and a cache-stability pin. Co-Authored-By: Claude Opus 4.8 --- src/context_system/prompt_assembly.py | 47 +++++++++++++++++ tests/test_live_base_system_prompt.py | 10 ++++ tests/test_prompt_assembly.py | 72 +++++++++++++++++++++++++++ 3 files changed, 129 insertions(+) diff --git a/src/context_system/prompt_assembly.py b/src/context_system/prompt_assembly.py index 7f3a90654..2eee16f66 100644 --- a/src/context_system/prompt_assembly.py +++ b/src/context_system/prompt_assembly.py @@ -351,10 +351,52 @@ def _compute_env_info(cwd: str) -> str: parts.append(f"OS: {platform.system()} {platform.release()}") # ch17 round-4 — date-only for cache stability (see _build_env_section). parts.append(f"Date: {_get_session_start_date_iso()}") + data_line = _clawcodex_data_dir_line() + if data_line: + parts.append(data_line) return "\n".join(parts) +def _clawcodex_data_dir_line() -> str | None: + """One-line pointer to clawcodex's session store for the env section. + + clawcodex is a distinct product from the real Claude Code harness and + keeps its previous-session state under a rebranded root — never + ``~/.claude``, which belongs to the other tool. Without this line the + model, when asked to inspect "previous session history", falls back on + its trained prior and greps ``~/.claude`` (or even a mangled + ``~/.Claude Code/sessions``), landing on the wrong tool's data or + nothing at all. + + Anchored on ``Path.home() / ".clawcodex"`` rather than + ``get_user_config_dir()``: the ``sessions/`` and ``transcripts/`` stores + are hardcoded to ``~/.clawcodex`` in every writer (``agent/session.py``, + ``agent/transcript.py``, ``services/session_storage.py``, …) and + deliberately do NOT honor ``$CLAWCODEX_CONFIG_DIR``, so this is their + invariant location in both the default and the override configuration. + (Config/memory/skills DO relocate under the override — but the model + doesn't need those to find session history, so we don't name them and + avoid emitting a path we can't back up. Anchoring on the override root + instead would authoritatively misdirect the model whenever the override + is set — strictly worse than the guessing this line replaces.) The value + is constant per process, so it is safe to embed in the REQUEST-scoped + # Environment block without busting the cache prefix. + """ + try: + from pathlib import Path + + root = str(Path.home() / ".clawcodex") + except Exception: + return None + return ( + f"clawcodex data directory: {root} — this tool's previous session " + f"history and transcripts live in the sessions/ and transcripts/ " + f"subdirectories here, NOT under ~/.claude (which belongs to a " + f"different tool)" + ) + + # --------------------------------------------------------------------------- # Full system prompt builder (R2-WS-5) # --------------------------------------------------------------------------- @@ -1092,6 +1134,11 @@ def _build_env_section(cwd: str | None, use_cache: bool) -> SystemPromptSection parts.append(f"- User: {getpass.getuser()}") except Exception: pass + # Tell the model where clawcodex keeps its own state so it stops guessing + # the real Claude Code harness's ~/.claude when asked about session history. + data_line = _clawcodex_data_dir_line() + if data_line: + parts.append(f"- {data_line}") content = "\n".join(parts) # Environment changes per request (CWD, date) diff --git a/tests/test_live_base_system_prompt.py b/tests/test_live_base_system_prompt.py index 101ff85e1..4a12d9a6d 100644 --- a/tests/test_live_base_system_prompt.py +++ b/tests/test_live_base_system_prompt.py @@ -66,6 +66,16 @@ def test_includes_base_sections(tmp_path: Path): assert marker in text, f"base section missing: {marker!r}" +def test_names_clawcodex_data_dir(tmp_path: Path): + """The live prompt tells the model where clawcodex keeps session history, + so it stops falling back on the real Claude Code harness's ~/.claude when + asked to inspect previous sessions.""" + ctx = ToolContext(workspace_root=tmp_path) + text = _joined(build_effective_system_prompt(_SENTINEL_STYLE, ctx)) + assert "clawcodex data directory:" in text + assert "NOT under ~/.claude" in text + + def test_appends_the_resolved_style(tmp_path: Path): """The resolved output-style prompt is appended (style still applied).""" ctx = ToolContext(workspace_root=tmp_path) diff --git a/tests/test_prompt_assembly.py b/tests/test_prompt_assembly.py index 32e98f1d9..23ac36961 100644 --- a/tests/test_prompt_assembly.py +++ b/tests/test_prompt_assembly.py @@ -10,6 +10,8 @@ from unittest.mock import patch from src.context_system.prompt_assembly import ( + _build_env_section, + _clawcodex_data_dir_line, _compute_env_info, _get_local_iso_date, append_system_context, @@ -627,6 +629,76 @@ def test_includes_cwd(self): self.assertIn("OS:", result) self.assertIn("Date:", result) + def test_includes_clawcodex_data_dir(self): + # Regression: the model must be told where clawcodex keeps session + # history so it stops guessing the real Claude Code harness's + # ~/.claude directory. + result = _compute_env_info("/test/path") + self.assertIn("clawcodex data directory:", result) + + +class TestClawcodexDataDirLine(unittest.TestCase): + """The env-section pointer to clawcodex's session store.""" + + def test_names_clawcodex_home_and_ignores_override(self): + # sessions/ and transcripts/ are hardcoded to ~/.clawcodex and do NOT + # honor $CLAWCODEX_CONFIG_DIR, so the line must name ~/.clawcodex in + # BOTH modes — never the override root, which would authoritatively + # misdirect the model (worse than the guessing this line replaces). + expected = os.path.join(os.path.expanduser("~"), ".clawcodex") + + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("CLAWCODEX_CONFIG_DIR", None) + line_default = _clawcodex_data_dir_line() + with patch.dict(os.environ, {"CLAWCODEX_CONFIG_DIR": "/custom/cc"}): + line_override = _clawcodex_data_dir_line() + + for line in (line_default, line_override): + assert line is not None + self.assertIn(expected, line) + self.assertIn("sessions/", line) + self.assertIn("transcripts/", line) + assert line_override is not None + self.assertNotIn("/custom/cc", line_override) + + def test_named_root_matches_actual_stores(self): + # Drift guard: if EITHER the sessions or the transcripts store is ever + # re-homed, this fails loudly rather than letting the system prompt + # silently point at a stale location. Both are pinned to their real + # production sources (not re-derived here) so the guard has teeth. + from pathlib import Path + + from src.agent.transcript import get_agent_transcript_path + from src.services.session_storage import SESSIONS_DIR + + line = _clawcodex_data_dir_line() + assert line is not None + # SESSIONS_DIR = /sessions ; transcripts = /transcripts. + # The line names , so each store's *parent* must appear in it. + self.assertIn(str(SESSIONS_DIR.parent), line) + transcripts_dir = Path(get_agent_transcript_path("drift-probe")).parent + self.assertIn(str(transcripts_dir.parent), line) + + def test_steers_away_from_dot_claude(self): + line = _clawcodex_data_dir_line() + assert line is not None + # Must warn against the OTHER tool's directory, and must not point the + # model at ~/.claude as the place to look. + self.assertIn("NOT under ~/.claude", line) + + def test_live_env_block_carries_the_line(self): + section = _build_env_section("/test/path", use_cache=False) + assert section is not None + self.assertIn("clawcodex data directory:", section.content) + + def test_env_block_stays_cache_stable(self): + # The line is a constant string, so two builds must be byte-identical + # (the # Environment block is on the REQUEST cache prefix). + s1 = _build_env_section("/test/path", use_cache=False) + s2 = _build_env_section("/test/path", use_cache=False) + assert s1 is not None and s2 is not None + self.assertEqual(s1.content, s2.content) + class TestClearContextCaches(unittest.TestCase): def test_no_crash(self):