|
3 | 3 |
|
4 | 4 | Cache invalidates automatically when behavior-affecting files change: |
5 | 5 | - .claude/skills/** (skill definitions) |
6 | | -- src/agent_profiles/base_agent/prompt.txt (prompt text) |
| 6 | +- src/agent_profiles/base_agent/prompt.txt (legacy fallback) and |
| 7 | + src/agent_profiles/officeqa_agent/prompt.md (prompt text) |
7 | 8 |
|
8 | 9 | Excludes metadata files like .claude/program.yaml to avoid unnecessary |
9 | 10 | cache invalidation when only scores or timestamps change. |
@@ -34,6 +35,22 @@ class CacheConfig(BaseModel): |
34 | 35 | store_messages: bool = False # Whether to cache the full messages list |
35 | 36 | hash_length: int = 12 # Length of hash prefix for filenames |
36 | 37 | cwd: Path = Path(".") # Working directory for git commands |
| 38 | + # Path to the LIVE `.claude/skills/` the agent reads from at run time. |
| 39 | + # When workspace ≠ project source root (typical with `--workspace`), |
| 40 | + # the workspace's `.claude/skills/` only contains stub snapshot files |
| 41 | + # — the real skills evolve in the project's `.claude/skills/`. Hashing |
| 42 | + # the workspace dir alone produces a stable cache key even as the |
| 43 | + # actual loaded skills change between iterations, returning stale |
| 44 | + # responses generated under different skill conditions. Pointing here |
| 45 | + # at the live dir fixes that. Defaults to `<cwd>/.claude/skills` for |
| 46 | + # backward compatibility (single-root setups where workspace == project). |
| 47 | + live_skills_dir: Path | None = None |
| 48 | + # Path to the project source root (parent of `src/agent_profiles/...`) |
| 49 | + # so the cache hashes the agent's actual prompt files. Without this, |
| 50 | + # `_get_tree_hash` looks for prompts at `<cwd>/src/agent_profiles/...` |
| 51 | + # which DOES NOT EXIST in a workspace setup → prompt edits go undetected. |
| 52 | + # Defaults to `cwd` for single-root backward compatibility. |
| 53 | + project_source_root: Path | None = None |
37 | 54 |
|
38 | 55 | class Config: |
39 | 56 | arbitrary_types_allowed = True |
@@ -86,35 +103,95 @@ def _get_tree_hash(self) -> str: |
86 | 103 | """ |
87 | 104 | Get combined hash of files that affect agent behavior. |
88 | 105 |
|
89 | | - Only hashes files that actually affect agent behavior: |
90 | | - - .claude/skills/** - skill definitions |
91 | | - - src/agent_profiles/base_agent/prompt.txt - prompt text |
| 106 | + Primary source of truth: the workspace's current git HEAD commit + |
| 107 | + the tree-hash of `.claude/skills/` on that commit. Since every |
| 108 | + program is a distinct branch with its own committed skill state, |
| 109 | + git already knows when things actually changed — using git's hashes |
| 110 | + sidesteps pathlib glob edge cases, stat-cache freshness, and |
| 111 | + uncommitted-working-tree drift. |
92 | 112 |
|
93 | | - Excludes metadata files like .claude/program.yaml which contain |
94 | | - mutable fields (score, created_at) that don't affect behavior. |
| 113 | + Falls back to the prior content-hashing logic only when git queries |
| 114 | + fail (e.g. workspace isn't a git repo). |
95 | 115 |
|
96 | 116 | Returns: |
97 | | - Combined hash of behavior-affecting files. |
| 117 | + Combined hash of behavior-affecting state. |
98 | 118 | """ |
99 | | - # Define paths that affect agent behavior |
100 | | - # (directory, glob_pattern) tuples |
101 | | - behavior_paths = [ |
102 | | - (".claude/skills", "**/*"), # All skill files |
103 | | - ("src/agent_profiles/base_agent", "prompt.txt"), # Prompt text |
| 119 | + # Three ingredients go into the hash: |
| 120 | + # (a) git HEAD — captures branch switches (each program = new branch). |
| 121 | + # (b) live file content of .claude/skills/ — captures uncommitted |
| 122 | + # working-tree edits (evolver writes before branch is created). |
| 123 | + # (c) prompt.txt content — captures prompt edits. |
| 124 | + # Mixing (a) and (b) means we detect BOTH "switched to a different |
| 125 | + # program branch" AND "wrote uncommitted skill changes" — either |
| 126 | + # forces a cache miss. |
| 127 | + git_token = self._git_behavior_token() or "no-git" |
| 128 | + |
| 129 | + # Hash the LIVE skills directory (where the agent actually reads |
| 130 | + # skill files from). When the loop runs with --workspace, the |
| 131 | + # live dir is on the project side (e.g. <EvoSkill>/.claude/skills), |
| 132 | + # not the workspace side (which carries only stub snapshots). If |
| 133 | + # we hash the workspace dir, the cache key stays constant across |
| 134 | + # iterations even as skills change, masking the evolver's effect. |
| 135 | + skills_dir = self.config.live_skills_dir or ( |
| 136 | + self.config.cwd / ".claude" / "skills" |
| 137 | + ) |
| 138 | + skills_h = self._hash_files(skills_dir, "**/*") if skills_dir.exists() else "" |
| 139 | + |
| 140 | + # Hash whichever agent prompts exist. Use the project source root |
| 141 | + # (where the actual `src/agent_profiles/...` tree lives), not cwd |
| 142 | + # — same workspace/project split issue as skills_dir above. |
| 143 | + source_root = self.config.project_source_root or self.config.cwd |
| 144 | + prompt_paths = [ |
| 145 | + source_root / "src" / "agent_profiles" / "base_agent" / "prompt.txt", |
| 146 | + source_root / "src" / "agent_profiles" / "officeqa_agent" / "prompt.md", |
104 | 147 | ] |
| 148 | + hasher = hashlib.sha256() |
| 149 | + for prompt_path in prompt_paths: |
| 150 | + try: |
| 151 | + if prompt_path.is_file(): |
| 152 | + with open(prompt_path, "rb") as f: |
| 153 | + hasher.update(prompt_path.name.encode("utf-8")) |
| 154 | + hasher.update(f.read()) |
| 155 | + except (IOError, OSError): |
| 156 | + pass |
| 157 | + prompt_h = hasher.hexdigest() |
105 | 158 |
|
106 | | - content_hashes = [] |
107 | | - for base_dir, pattern in behavior_paths: |
108 | | - dir_path = self.config.cwd / base_dir |
109 | | - if dir_path.exists(): |
110 | | - content_hashes.append(self._hash_files(dir_path, pattern)) |
111 | | - else: |
112 | | - content_hashes.append("") |
113 | | - |
114 | | - # Combine hashes into a single hash |
115 | | - combined = ":".join(content_hashes) |
| 159 | + combined = f"git:{git_token}|skills:{skills_h}|prompt:{prompt_h}" |
116 | 160 | return hashlib.sha256(combined.encode("utf-8")).hexdigest() |
117 | 161 |
|
| 162 | + def _git_behavior_token(self) -> str | None: |
| 163 | + """Return a git-derived token for the workspace's current skill state. |
| 164 | +
|
| 165 | + Uses the **root tree SHA** (`git rev-parse HEAD^{tree}`) plus the |
| 166 | + skills tree SHA. Tree SHAs are content-addressed: identical content |
| 167 | + produces the same SHA regardless of when the commit was made. Using |
| 168 | + the commit SHA (HEAD) instead would break cache reuse across |
| 169 | + `--fresh` runs because each run creates a new "Create program: base" |
| 170 | + commit with a fresh timestamp → different commit SHA, identical |
| 171 | + content. Tree SHAs sidestep this by hashing the snapshot, not the |
| 172 | + commit metadata. |
| 173 | +
|
| 174 | + Returns None when git isn't available, the cwd isn't a repo, or |
| 175 | + `.claude/skills` isn't tracked. |
| 176 | + """ |
| 177 | + try: |
| 178 | + import subprocess |
| 179 | + root_tree = subprocess.check_output( |
| 180 | + ["git", "rev-parse", "HEAD^{tree}"], |
| 181 | + cwd=self.config.cwd, text=True, stderr=subprocess.DEVNULL, |
| 182 | + ).strip() |
| 183 | + try: |
| 184 | + skills_tree = subprocess.check_output( |
| 185 | + ["git", "rev-parse", "HEAD:.claude/skills"], |
| 186 | + cwd=self.config.cwd, text=True, stderr=subprocess.DEVNULL, |
| 187 | + ).strip() |
| 188 | + except subprocess.CalledProcessError: |
| 189 | + # Skills dir not tracked on this commit → use empty-tree SHA |
| 190 | + skills_tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" |
| 191 | + return f"{root_tree}:{skills_tree}" |
| 192 | + except (subprocess.CalledProcessError, FileNotFoundError, OSError): |
| 193 | + return None |
| 194 | + |
118 | 195 | def _hash_files(self, dir_path: Path, pattern: str = "**/*") -> str: |
119 | 196 | """ |
120 | 197 | Compute content hash of files matching a pattern in a directory. |
|
0 commit comments