Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 104 additions & 16 deletions mempalace/hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -670,36 +670,124 @@ def _parse_harness_input(data: dict, harness: str) -> dict:
}


# Common parent-dir tokens stripped from the encoded folder when no
# explicit ``-Projects-`` segment is present. Order matters: only the
# first match strips. These cover the bulk of Unix layouts; cwd-from-JSONL
# (the primary path) handles the long tail correctly without heuristics.
_ENCODED_PARENT_PREFIXES = (
"git-",
"dev-",
"projects-",
"Projects-",
"src-",
"code-",
"work-",
"Documents-",
)


def _wing_from_jsonl_cwd(transcript_path: str) -> Optional[str]:
"""Read ``cwd`` from the first JSONL line that records it.

Claude Code stores the absolute working directory on most message
types (tool_use, tool_result, user/assistant turns), but not all
(e.g. queue-operation lines lack it). Scan up to 200 lines to find
the first record that includes a non-empty cwd, then derive the
wing from its leaf path segment. Returns ``None`` if the file is
unreadable, empty, or contains no cwd.
"""
try:
path = Path(transcript_path).expanduser()
if not path.is_file():
return None
Comment on lines +700 to +702

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For better security and consistency with other path-handling functions in this module, consider using the existing _validate_transcript_path helper. This ensures the path is resolved, has a valid extension, and doesn't contain directory traversal segments before attempting to open it.

Suggested change
path = Path(transcript_path).expanduser()
if not path.is_file():
return None
path = _validate_transcript_path(transcript_path)
if path is None or not path.is_file():
return None

with path.open("r", encoding="utf-8", errors="replace") as f:
for i, line in enumerate(f):
if i >= 200:
break
line = line.strip()
if not line or '"cwd"' not in line:
continue
try:
data = json.loads(line)
except (json.JSONDecodeError, ValueError):
continue
cwd = data.get("cwd")
Comment on lines +710 to +714

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The json.loads(line) call can return non-dictionary types (such as lists or strings) if the JSONL line is malformed or contains unexpected data. In such cases, data.get("cwd") will raise an AttributeError. Adding AttributeError to the caught exceptions or verifying that data is a dictionary would make this more robust, similar to how it's handled in _count_human_messages and _extract_recent_messages.

Suggested change
try:
data = json.loads(line)
except (json.JSONDecodeError, ValueError):
continue
cwd = data.get("cwd")
try:
data = json.loads(line)
cwd = data.get("cwd")
except (json.JSONDecodeError, ValueError, AttributeError):
continue

if not cwd or not isinstance(cwd, str):
continue
cwd_norm = cwd.replace("\\", "/").rstrip("/")
if not cwd_norm:
continue
project = cwd_norm.rsplit("/", 1)[-1]
if project:
slug = project.lower().replace(" ", "_").replace("-", "_")
return f"wing_{slug}"
except OSError:
pass
return None


def _wing_from_transcript_path(transcript_path: str) -> str:
"""Derive a project wing name from a Claude Code transcript path.

Claude Code encodes the project's source directory by replacing path
separators with dashes, producing folders like:
~/.claude/projects/-home-<user>-Projects-<project>/session.jsonl
~/.claude/projects/-home-<user>-dev-<parent>-<project>/session.jsonl
~/.claude/projects/-Users-<user>-<folder>-<project>/session.jsonl
Strategy (in priority order):

1. PRIMARY — Read ``cwd`` from the JSONL transcript. Claude Code records
the absolute working directory on most message types, so the project
name is whatever the leaf path segment of cwd is. This is the
canonical answer when present.

2. FALLBACK — Decode the encoded folder under ``.claude/projects/``.
Claude Code flattens path separators to dashes (``/Users/me/code/foo``
→ ``-Users-me-code-foo``), so the original directory boundaries are
lost. We strip the platform user-home prefix (``Users-<user>-`` or
``home-<user>-``) and one common parent-dir token (``git-``, ``dev-``,
``projects-``, etc.), then convert the remaining dashes to
underscores. Unlike the previous "last token only" heuristic, this
never silently truncates a hyphenated project folder name like
``claude-code``, ``react-native``, or ``customer-portal``.

3. LEGACY — Match an explicit ``-Projects-<name>`` segment for
transcripts not under the standard Claude Code projects dir.

4. DEFAULT — ``wing_sessions``.

The project directory name is the final dash-separated token of the
encoded folder. Returns ``wing_<project>`` (lowercased, spaces → ``_``).
Falls back to ``wing_sessions`` if the path does not match a Claude Code
project-folder layout.
Closes #1410.
"""
# 1. Primary — cwd from JSONL is the canonical source of truth
cwd_wing = _wing_from_jsonl_cwd(transcript_path)
if cwd_wing:
return cwd_wing

# Normalize path separators for cross-platform (Windows backslashes)
normalized = transcript_path.replace("\\", "/")
# Primary: pull the encoded project folder out of ``.claude/projects/``
# and take its last dash-separated token.

# 2. Fallback — encoded project folder under .claude/projects/
match = re.search(r"/\.claude/projects/-([^/]+)", normalized)
if match:
encoded = match.group(1)
project = encoded.rsplit("-", 1)[-1]
# Strip platform user-home prefix so the wing isn't dominated by
# /Users/<user>/ or /home/<user>/.
m = re.match(r"(?:Users|home)-[^-]+-(.+)", encoded)
if m:
encoded = m.group(1)
# Strip one common parent-dir token if present, keeping the rest as
# the project path. Hyphens become underscores to preserve
# uniqueness for hyphenated project folder names.
for prefix in _ENCODED_PARENT_PREFIXES:
if encoded.startswith(prefix):
encoded = encoded[len(prefix) :]
break
project = encoded.lower().replace(" ", "_").replace("-", "_")
if project:
return f"wing_{project.lower().replace(' ', '_')}"
# Legacy fallback: explicit ``-Projects-<name>`` segment, useful for
# transcripts not under the standard Claude Code projects dir.
return f"wing_{project}"

# 3. Legacy — explicit -Projects-<name> segment
match = re.search(r"-Projects-([^/]+?)(?:/|$)", normalized)
if match:
project = match.group(1).lower().replace(" ", "_")
project = match.group(1).lower().replace(" ", "_").replace("-", "_")
return f"wing_{project}"

# 4. Default
return "wing_sessions"


Expand Down
160 changes: 155 additions & 5 deletions tests/test_hooks_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -328,21 +328,171 @@ def test_wing_from_transcript_path_lowercases():


def test_wing_from_transcript_path_non_projects_layout():
# Linux users with code under ~/dev/, ~/src/, ~/code/ — no -Projects- segment.
# Project name is the final dash-separated token of the encoded folder.
# Linux user with code under ~/dev/. The encoded form ``dev-MemPalace-mempalace``
# is ambiguous between ``~/dev/MemPalace/mempalace/`` (project = mempalace) and
# ``~/dev/MemPalace-mempalace/`` (hyphenated single-name project). With no JSONL
# cwd to disambiguate, we preserve all post-``dev-`` segments rather than silently
# truncating to the last token (which would drop ``MemPalace`` here and collide
# with any other ``-mempalace`` leaf elsewhere on the system).
path = "/home/igor/.claude/projects/-home-igor-dev-MemPalace-mempalace/session.jsonl"
assert _wing_from_transcript_path(path) == "wing_mempalace"
assert _wing_from_transcript_path(path) == "wing_mempalace_mempalace"


def test_wing_from_transcript_path_macos_users_layout():
# macOS ~/ layout without a Projects/ segment.
# macOS ~/ layout without a Projects/ segment — single-token project name
# so the heuristic produces the same result as the leaf-only approach.
path = "/Users/alice/.claude/projects/-Users-alice-code-MyApp/session.jsonl"
assert _wing_from_transcript_path(path) == "wing_myapp"


def test_wing_from_transcript_path_nested_deep():
# Deep tree: ``-home-bob-work-clients-acme-frontend``. Without JSONL cwd we
# can't tell whether ``frontend`` is the project, ``acme-frontend`` is a
# hyphenated project, or the project lives several levels in. Strip the
# user-home and one common parent (``work-``), then keep the remaining
# path as the wing — collision-safe even if multiple clients have a
# ``frontend/`` subdir.
path = "/home/bob/.claude/projects/-home-bob-work-clients-acme-frontend/session.jsonl"
assert _wing_from_transcript_path(path) == "wing_frontend"
assert _wing_from_transcript_path(path) == "wing_clients_acme_frontend"


# --- _wing_from_transcript_path: hyphenated project names (issue #1410) ---


def test_wing_from_transcript_path_hyphenated_claude_code():
"""Regression: ``claude-code`` was truncated to ``wing_code`` (#1410)."""
path = "/Users/me/.claude/projects/-Users-me-claude-code/abc.jsonl"
assert _wing_from_transcript_path(path) == "wing_claude_code"


def test_wing_from_transcript_path_hyphenated_react_native():
"""Regression: ``react-native`` was truncated to ``wing_native`` (#1410)."""
path = "/Users/me/.claude/projects/-Users-me-react-native/abc.jsonl"
assert _wing_from_transcript_path(path) == "wing_react_native"


def test_wing_from_transcript_path_no_collision_between_hyphenated_siblings():
"""Regression: ``customer-portal`` and ``admin-portal`` both truncated to
``wing_portal`` under the old heuristic, merging diary entries from two
independent projects into one wing (#1410)."""
customer = _wing_from_transcript_path(
"/Users/me/.claude/projects/-Users-me-customer-portal/abc.jsonl"
)
admin = _wing_from_transcript_path(
"/Users/me/.claude/projects/-Users-me-admin-portal/abc.jsonl"
)
assert customer == "wing_customer_portal"
assert admin == "wing_admin_portal"
assert customer != admin


def test_wing_from_transcript_path_strips_parent_dir_with_hyphenated_project():
"""Reporter's example: ``-home-alice-projects-react-native`` should keep
the full project name after stripping the ``projects-`` parent (#1410)."""
path = "/home/alice/.claude/projects/-home-alice-projects-react-native/abc.jsonl"
assert _wing_from_transcript_path(path) == "wing_react_native"


# --- _wing_from_transcript_path: cwd-from-JSONL primary path ---


def test_wing_from_transcript_path_uses_cwd_from_jsonl(tmp_path):
"""When the JSONL records ``cwd``, the leaf segment of cwd is the wing —
even if the encoded folder name would have produced a different (and
noisier) wing."""
# Encoded folder says ``-home-igor-dev-MemPalace-mempalace`` (would yield
# ``wing_mempalace_mempalace`` via fallback), but cwd is the truth.
project_dir = tmp_path / "-home-igor-dev-MemPalace-mempalace"
project_dir.mkdir()
transcript = project_dir / "session.jsonl"
transcript.write_text(
'{"type":"queue-operation","operation":"enqueue","timestamp":"2026-05-09T00:00:00Z"}\n'
'{"type":"user","cwd":"/home/igor/dev/MemPalace/mempalace","content":"hi"}\n',
encoding="utf-8",
)
assert _wing_from_transcript_path(str(transcript)) == "wing_mempalace"


def test_wing_from_transcript_path_cwd_with_hyphenated_project(tmp_path):
"""cwd primary path correctly handles hyphenated project names without
truncation."""
project_dir = tmp_path / "-Users-me-claude-code"
project_dir.mkdir()
transcript = project_dir / "session.jsonl"
transcript.write_text(
'{"type":"user","cwd":"/Users/me/git/claude-code","content":"hi"}\n',
encoding="utf-8",
)
assert _wing_from_transcript_path(str(transcript)) == "wing_claude_code"


def test_wing_from_transcript_path_cwd_skips_lines_without_cwd(tmp_path):
"""Lines that lack ``cwd`` (queue-operation, etc.) are skipped; the first
line that records cwd wins."""
project_dir = tmp_path / "-Users-me-foo"
project_dir.mkdir()
transcript = project_dir / "session.jsonl"
lines = [
'{"type":"queue-operation","operation":"enqueue"}',
'{"type":"queue-operation","operation":"dequeue"}',
'{"type":"queue-operation","operation":"complete"}',
'{"type":"tool_use","cwd":"/Users/me/work/real-project","content":"ok"}',
'{"type":"user","cwd":"/Users/me/somewhere-else","content":"later"}',
]
transcript.write_text("\n".join(lines) + "\n", encoding="utf-8")
# First cwd record wins (line 4, real-project).
assert _wing_from_transcript_path(str(transcript)) == "wing_real_project"


def test_wing_from_transcript_path_cwd_falls_back_when_no_cwd_in_jsonl(tmp_path):
"""If no JSONL line has cwd, fall through to the encoded-folder heuristic."""
project_dir = tmp_path / "-Users-me-no-cwd-project"
project_dir.mkdir()
transcript = project_dir / "session.jsonl"
transcript.write_text(
'{"type":"queue-operation","operation":"enqueue"}\n'
'{"type":"queue-operation","operation":"complete"}\n',
encoding="utf-8",
)
# tmp_path leaks into the path before .claude/projects, so the regex
# won't match and we hit the wing_sessions default. The point of this
# test: the cwd reader doesn't crash and returns None cleanly.
result = _wing_from_transcript_path(str(transcript))
assert result == "wing_sessions"


def test_wing_from_transcript_path_cwd_handles_malformed_jsonl(tmp_path):
"""Malformed JSON lines must not crash the wing extraction."""
project_dir = tmp_path / "-Users-me-broken-project"
project_dir.mkdir()
transcript = project_dir / "session.jsonl"
transcript.write_text(
"this is not json at all\n"
'{"type":"broken",\n' # truncated mid-record
'{"type":"valid","cwd":"/Users/me/git/clean-name","content":"ok"}\n',
encoding="utf-8",
)
assert _wing_from_transcript_path(str(transcript)) == "wing_clean_name"


def test_wing_from_transcript_path_cwd_handles_missing_file():
"""Nonexistent transcript path falls back cleanly to the encoded heuristic."""
path = "/Users/me/.claude/projects/-Users-me-claude-code/does-not-exist.jsonl"
assert _wing_from_transcript_path(path) == "wing_claude_code"


def test_wing_from_transcript_path_cwd_handles_non_string_cwd(tmp_path):
"""A cwd field that isn't a string (e.g. null, number) must be skipped."""
project_dir = tmp_path / "-Users-me-fallback-name"
project_dir.mkdir()
transcript = project_dir / "session.jsonl"
transcript.write_text(
'{"type":"x","cwd":null}\n'
'{"type":"x","cwd":42}\n'
'{"type":"x","cwd":"/Users/me/git/proper-name"}\n',
encoding="utf-8",
)
assert _wing_from_transcript_path(str(transcript)) == "wing_proper_name"


# --- _log ---
Expand Down
Loading