From 4f22cbb05cecd08b42433b6ec19bb926c67e4613 Mon Sep 17 00:00:00 2001 From: Parideboy Date: Wed, 8 Jul 2026 06:44:09 +0200 Subject: [PATCH] fix(learn): decode Windows drive-style project dirs with dotted usernames (#1855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Fixes #1849. On Windows, `headroom learn --all --apply` failed to write recommendations for every project when the username contains a dot (e.g. `pradipe.yoggi`), reporting `[WinError 161] The specified path is invalid: '\\\Users\...'`. Root cause: Claude Code encodes `C:\Users\first.last\proj` as `C--Users-first-last-proj` — **no leading dash** (the path starts with the drive letter), and `:` + `\` each collapse to `-`, producing a double dash after the drive letter. Two defects followed: 1. `_decode_project_path()` required `escaped_name.startswith("-")` and returned `None` for every real Windows encoding, so the greedy filesystem-walking decoder (which correctly rejoins dotted components like `first.last`) was unreachable. 2. The `discover_projects()` fallback blindly stripped the first character (`entry.name[1:]`), turning `C--Users-...` into `--Users-...`, whose dash→slash replacement yields the invalid `\\\Users\first\last\proj` seen in the issue. ## Type of Change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Refactoring (no functional changes) ## Changes Made - `headroom/learn/plugins/claude.py` - New `_decode_windows_path(drive, parts)` helper: drops empty split tokens (so separators are never doubled), checks the literal path, greedy-decodes from the drive root (so `Users` → `first.last` is rejoined from the real filesystem via the existing `_component_tokenizations` dot-split), and keeps the trust-`Users` literal fallback. - `_decode_project_path()` now matches both the real drive-style encoding `C--Users-...` (no leading dash) and the legacy `-C-Users-...` form via `^-?([A-Za-z])--?(.+)$`, routing both through the helper; POSIX logic unchanged. - `discover_projects()` fallback applies the same normalization instead of stripping the first character, so nonexistent projects still get a *valid* `C:\Users\...` path instead of `\\\Users\...`. - `tests/test_learn/test_scanner.py`: three new tests — double-dash encoding decodes without doubled separators; dotted username rejoined via greedy decode on a real directory tree (Windows-only); `discover_projects` fallback produces a valid path for a nonexistent `C--Users-...` project. ## Testing - [x] Existing tests pass locally - [x] Added new tests covering the change ``` $ python -m pytest tests/test_learn -q 3 failed, 211 passed, 5 skipped in 7.22s # The 3 failures (test_home_dir_username_stays_single_component, # test_includes_project_info, test_double_write_replaces_not_appends) are # pre-existing Windows-local failures, verified identical on a clean # upstream/main checkout via git stash — none introduced by this change. $ ruff check headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py && ruff format --check ... All checks passed! $ mypy headroom --ignore-missing-imports Success: no issues found ``` ## Real Behavior Proof - Environment: Windows 11 Pro, PowerShell, Python 3.14, headroom built from this branch (Rust core built locally) - Exact command / steps: `python -c "from headroom.learn.plugins.claude import _decode_project_path as d; print(d('G--Programmi-Aggiuntivi-headroom')); print(d('C--Users-esiri-AppData-Local-Temp'))"` — decoding this machine's own real `~/.claude/projects` directory names (which use the drive-style encoding this PR fixes; note `Programmi Aggiuntivi` contains a space, exercising the greedy multi-token rejoin just like a dotted username) - Observed result: `G:\Programmi Aggiuntivi\headroom` and `C:\Users\esiri\AppData\Local\Temp` — both correct real paths. On upstream/main the same call returns `None` for both, which is what pushed `learn --all` into the mangling fallback. - Not tested: an actual Active Directory `first.last` account end-to-end (no such account available); covered instead by the Windows-only greedy-decode test against a real `john.doe` directory tree and by the space-in-path live decode above, which exercises the identical code path. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review Co-authored-by: Claude Fable 5 --- headroom/learn/plugins/claude.py | 58 ++++++++++++++++++++++---------- tests/test_learn/test_scanner.py | 50 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 18 deletions(-) diff --git a/headroom/learn/plugins/claude.py b/headroom/learn/plugins/claude.py index 2753c8c20..79d1b331d 100644 --- a/headroom/learn/plugins/claude.py +++ b/headroom/learn/plugins/claude.py @@ -70,12 +70,14 @@ class ClaudeCodePlugin(LearnPlugin, ConversationScanner): project_path = _decode_project_path(entry.name) if project_path is None: - fallback_parts = entry.name[1:].split("-") - if len(fallback_parts[0]) == 1 and fallback_parts[0].isalpha(): - drive = fallback_parts[0].upper() - project_path = Path(f"{drive}:\\" + "\\".join(fallback_parts[1:])) + win = re.match(r"^-?([A-Za-z])--?(.+)$", entry.name) + if win: + drive = win.group(1).upper() + tokens = [p for p in win.group(2).split("-") if p] + project_path = Path(f"{drive}:\\" + "\\".join(tokens)) else: - project_path = Path("/" + entry.name[1:].replace("-", "/")) + stripped = entry.name.lstrip("-") + project_path = Path("/" + stripped.replace("-", "/")) name = _project_display_name(project_path, entry.name) @@ -335,8 +337,41 @@ class ClaudeCodePlugin(LearnPlugin, ConversationScanner): # ============================================================================= +def _decode_windows_path(drive: str, parts: list[str]) -> Path | None: + """Reconstruct a Windows path from drive letter + dash-split tokens. + + Empty tokens (from consecutive dashes in the encoded name) are dropped so + the literal join never produces doubled separators. + """ + tokens = [p for p in parts if p] + if not tokens: + return None + win_path = Path(f"{drive}:\\" + "\\".join(tokens)) + if win_path.exists(): + return win_path + drive_root = Path(f"{drive}:\\") + if drive_root.exists(): + result = _greedy_path_decode(drive_root, tokens) + if result: + return result + if tokens[0].lower() == "users": + return win_path + return None + + def _decode_project_path(escaped_name: str) -> Path | None: """Decode a Claude Code escaped project path.""" + # Windows paths are encoded without a leading dash: "C:\Users\x" becomes + # "C--Users-x" (":" and "\" each collapse to "-"). Older callers also pass + # the legacy "-C-Users-x" form; accept both. + win = re.match(r"^-?([A-Za-z])--?(.+)$", escaped_name) + if win: + result = _decode_windows_path(win.group(1).upper(), win.group(2).split("-")) + if result is not None: + return result + if not escaped_name.startswith("-"): + return None + if not escaped_name.startswith("-"): return None @@ -344,19 +379,6 @@ def _decode_project_path(escaped_name: str) -> Path | None: if len(parts) < 2: return None - if len(parts[0]) == 1 and parts[0].isalpha(): - drive = parts[0].upper() - win_path = Path(f"{drive}:\\" + "\\".join(parts[1:])) - if win_path.exists(): - return win_path - win_base = Path(f"{drive}:\\{parts[1]}") if len(parts) > 1 else win_path - if win_base.exists() and len(parts) > 2: - result = _greedy_path_decode(win_base, parts[2:]) - if result: - return result - if len(parts) > 1 and parts[1].lower() == "users": - return win_path - simple = Path("/" + escaped_name[1:].replace("-", "/")) if simple.exists(): return simple diff --git a/tests/test_learn/test_scanner.py b/tests/test_learn/test_scanner.py index 295c6fe59..2e5aafd04 100644 --- a/tests/test_learn/test_scanner.py +++ b/tests/test_learn/test_scanner.py @@ -391,6 +391,56 @@ class TestDecodeProjectPath: assert projects[0].name == "work" assert str(projects[0].project_path).startswith("C:") + def test_windows_double_dash_encoding_decodes(self) -> None: + """Real Claude Code encoding has no leading dash: C:\\Users\\x → C--Users-x (#1849). + + The drive colon and first backslash each flatten to '-', producing a + double dash after the drive letter. The decoder must not emit doubled + path separators from the resulting empty split token. + """ + result = _decode_project_path("C--Users-jane-proj") + + assert result is not None + rendered = str(result) + assert rendered.startswith("C:") + assert "\\\\" not in rendered.removeprefix("C:") + assert rendered == "C:\\Users\\jane\\proj" + + def test_windows_double_dash_dotted_username_via_greedy(self) -> None: + """C--...-first-last-... must rejoin 'first.last' when the dir exists (#1849).""" + import sys + import tempfile + + if sys.platform != "win32": + pytest.skip("greedy Windows-path decode requires real Windows filesystem") + + with tempfile.TemporaryDirectory() as td: + project = Path(td) / "john.doe" / "work" + project.mkdir(parents=True) + + drive = Path(td).drive[0] + rest = str(project)[3:] # strip 'C:\\' + encoded = f"{drive}--" + rest.replace("\\", "-").replace(".", "-").replace(" ", "-") + + result = _decode_project_path(encoded) + assert result == project + + def test_discover_double_dash_windows_project_fallback(self, tmp_path: Path) -> None: + """Nonexistent C--Users-... project must fall back to a valid path, not \\\\\\Users (#1849).""" + claude_dir = tmp_path / ".claude" + project_dir = claude_dir / "projects" / "C--Users-jane-proj" + project_dir.mkdir(parents=True) + (project_dir / "session.jsonl").write_text("{}\n") + + projects = ClaudeCodeScanner(claude_dir=claude_dir).discover_projects() + + assert len(projects) == 1 + assert projects[0].name == "proj" + rendered = str(projects[0].project_path) + assert rendered.startswith("C:") + assert "\\\\" not in rendered.removeprefix("C:") + assert not rendered.startswith("\\") + def test_home_dir_username_stays_single_component(self) -> None: """A home-directory name must survive decoding as one component.