fix(learn): decode Windows drive-style project dirs with dotted usernames (#1855)

## 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 <noreply@anthropic.com>
This commit is contained in:
Parideboy 2026-07-08 06:44:09 +02:00 committed by GitHub
parent 140d6e4f96
commit 4f22cbb05c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 90 additions and 18 deletions

View file

@ -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

View file

@ -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.