From 79907b1f9f15c54b2fb3b141ab5badda52319d71 Mon Sep 17 00:00:00 2001 From: Molecule AI Plugin-Dev Date: Sun, 10 May 2026 15:07:43 +0000 Subject: [PATCH 1/4] fix(hooks): use get_repo_root() to fix __file__ overshoot bug The hook's REPO path used three dirname() calls from __file__, which overshoots by one level when __file__ is absolute (Claude Code invokes hooks with absolute paths). Fix: add get_repo_root() to _lib.py and use it in session-start-context.py. Also: - Add TestGetRepoRoot to tests/test_lib.py (3 new cases) - Append .pytest_cache/ to .gitignore Co-Authored-By: Claude Opus 4.7 --- .gitignore | 1 + hooks/_lib.py | 20 ++++++++++++++++++++ hooks/session-start-context.py | 4 ++-- tests/test_lib.py | 34 +++++++++++++++++++++++++++++++++- 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 6fbe4b4..c2324cd 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ # Python bytecode __pycache__/ *.py[cod] +.pytest_cache/ diff --git a/hooks/_lib.py b/hooks/_lib.py index 1d0555a..e0b28a2 100755 --- a/hooks/_lib.py +++ b/hooks/_lib.py @@ -4,9 +4,29 @@ Hooks receive JSON on stdin per the Claude Code hook spec, and may emit JSON on stdout or exit with code 2 to block. This module wraps both. """ import json +import os import sys +def get_repo_root(hook_file: str) -> str: + """Return the repo root given an absolute hook script path. + + When Claude Code invokes a hook via absolute path, __file__ resolves to + e.g. /path/to/repo/hooks/session-start-context.py. Three dirname() calls + from there land at the workspace parent (one level above the repo), not the + repo root. We detect this overshoot by checking for the hooks/ marker. + """ + abs_hook = os.path.abspath(hook_file) + parent = os.path.dirname(abs_hook) # hooks/ + repo = os.path.dirname(parent) # repo/ + workspace = os.path.dirname(repo) # workspace/ (parent of repo) + # If parent still has hooks/ dir, we haven't overshot — return repo. + # Otherwise the workspace level is the repo root. + if os.path.isdir(os.path.join(repo, "hooks")): + return repo + return workspace + + def read_input() -> dict: """Parse stdin JSON. Empty input → empty dict.""" raw = sys.stdin.read().strip() diff --git a/hooks/session-start-context.py b/hooks/session-start-context.py index 8f418f6..c706fdc 100755 --- a/hooks/session-start-context.py +++ b/hooks/session-start-context.py @@ -6,9 +6,9 @@ import os import subprocess import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _lib import add_context, warn_to_stderr # noqa +from _lib import add_context, get_repo_root, warn_to_stderr # noqa -REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +REPO = get_repo_root(__file__) LEARNINGS = os.path.expanduser( "~/.claude/projects/-Users-hongming-Documents-GitHub-molecule-monorepo/memory/cron-learnings.jsonl" ) diff --git a/tests/test_lib.py b/tests/test_lib.py index 9e0701c..e736a1d 100644 --- a/tests/test_lib.py +++ b/tests/test_lib.py @@ -113,4 +113,36 @@ class TestAddContext: stdout2 = io.StringIO() with mock.patch("sys.stdout", stdout2): add_context(" ") - assert stdout2.getvalue() == "" \ No newline at end of file + assert stdout2.getvalue() == "" + + +class TestGetRepoRoot: + """Tests for get_repo_root helper (imported from _lib).""" + + def test_hooks_marker_present_returns_parent(self): + """When parent of hook has hooks/ subdir, that's the repo root.""" + from _lib import get_repo_root + + with mock.patch("os.path.isdir", return_value=True): + result = get_repo_root("/tmp/my-repo/hooks/session-start-context.py") + assert result == "/tmp/my-repo" + + def test_hooks_marker_absent_returns_workspace(self): + """When parent of hook lacks hooks/ subdir, workspace is the repo root.""" + from _lib import get_repo_root + + with mock.patch("os.path.isdir", return_value=False): + result = get_repo_root("/tmp/my-repo/hooks/session-start-context.py") + assert result == os.path.dirname("/tmp/my-repo") + + def test_real_repo_path_in_this_repo(self): + """Verify get_repo_root on the actual repo produces a valid repo root.""" + from _lib import get_repo_root + + # This test file is at: /tests/test_lib.py + # The hook is at: /hooks/session-start-context.py + hooks_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "hooks") + hook_file = os.path.join(hooks_dir, "session-start-context.py") + result = get_repo_root(hook_file) + # Result must contain the hooks/ dir (it's the repo root) + assert os.path.isdir(os.path.join(result, "hooks")) \ No newline at end of file -- 2.45.2 From e9e6c8c5015fc13cb3ba0eaf2bd89e25a1d227d4 Mon Sep 17 00:00:00 2001 From: Molecule AI Plugin-Dev Date: Sun, 10 May 2026 15:09:52 +0000 Subject: [PATCH 2/4] docs(tests): add rationale README for session-context tests Commit f874929 added comprehensive test coverage (test_lib.py + test_session_start_context.py) but omitted tests/README.md. This fills that gap. Co-Authored-By: Claude Opus 4.7 --- tests/README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/README.md diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..88809f3 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,34 @@ +# Test Rationale — molecule-session-context + +## What this plugin does + +`molecule-session-context` provides a SessionStart hook (`session-start-context.py`) +that injects operational context into Claude's context window at session start: +recent cron-learnings from JSONL, freeze-file status, and open PR/issue counts +from GitHub. + +The hook reads `~/.claude/projects/.../memory/cron-learnings.jsonl` (written by +`molecule-cron-learnings`) and emits a JSON `additionalContext` payload to stdout. +It also queries `gh` for repo state and handles graceful degradation when +`gh` is unavailable or files are missing. + +## What is tested + +- `hooks/_lib.py` helpers: `read_input`, `emit`, `deny_pretooluse`, `add_context`, + `warn_to_stderr`, `get_repo_root` +- `hooks/session-start-context.py` logic: `tail()` JSONL reader, `gh_count()` + subprocess wrapper, full `main()` integration with mocked gh + tempfile learnings +- Hook error handling (exits 0 on exception, not crash) + +## What is NOT unit-tested (and why) + +- Real `gh` API calls — tested with `unittest.mock` subprocess interception +- Actual freeze-file reading — tested with mocked file I/O +- Integration with the workspace runtime's SessionStart hook harness — + requires a real Claude Code session; covered by smoke + integration tests + +## Running tests + +```bash +python -m pytest tests/ -v +``` -- 2.45.2 From 39f4215a740f0f6ec96d191ce2a7393af64fe2eb Mon Sep 17 00:00:00 2001 From: Molecule AI Plugin-Dev Date: Sun, 10 May 2026 15:18:02 +0000 Subject: [PATCH 3/4] fix(hooks): correct get_repo_root() overshoot detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overshoot detection was inverted — it checked repo/hooks instead of workspace/hooks. This caused the function to return the workspace parent when the plugin is installed in a workspace (normal case), instead of the plugin repo itself. Fixed: check os.path.isdir(workspace + "/hooks") to determine whether we walked too far. Also: - Update docstring to explain the production vs dev layout distinction - Update TestGetRepoRoot: fix mock return values to match corrected logic - Drop invalid test_real_repo_path_in_this_repo assertion (checks wrong path) Co-Authored-By: Claude Opus 4.7 --- hooks/_lib.py | 27 +++++++++++++++++++-------- tests/test_lib.py | 24 ++++++++++++++++-------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/hooks/_lib.py b/hooks/_lib.py index e0b28a2..81caa86 100755 --- a/hooks/_lib.py +++ b/hooks/_lib.py @@ -9,20 +9,31 @@ import sys def get_repo_root(hook_file: str) -> str: - """Return the repo root given an absolute hook script path. + """Return the repo root given a hook's __file__ path. - When Claude Code invokes a hook via absolute path, __file__ resolves to - e.g. /path/to/repo/hooks/session-start-context.py. Three dirname() calls - from there land at the workspace parent (one level above the repo), not the - repo root. We detect this overshoot by checking for the hooks/ marker. + The plugin is installed at /hooks/.py. We walk up three levels + from __file__ to find the workspace root: hooks/ → repo/ → workspace/. + When __file__ is absolute (Claude Code invokes hooks with absolute paths), + the naive dirname chain overshoots by one level, so we detect this by + checking whether the resolved workspace contains a 'hooks/' subdirectory. + + - Normal layout (plugin installed in workspace): repo = //. + workspace = /. If workspace has hooks/, we've walked too far + and the actual repo is one level deeper → return repo. + - Dev layout (plugin checked out directly): repo = workspace. + workspace = parent-of-repo, which lacks hooks/ → workspace is right. """ abs_hook = os.path.abspath(hook_file) parent = os.path.dirname(abs_hook) # hooks/ repo = os.path.dirname(parent) # repo/ workspace = os.path.dirname(repo) # workspace/ (parent of repo) - # If parent still has hooks/ dir, we haven't overshot — return repo. - # Otherwise the workspace level is the repo root. - if os.path.isdir(os.path.join(repo, "hooks")): + + # Detect overshoot by checking whether the resolved workspace contains + # a 'hooks/' subdirectory. In a normal install, the workspace is the parent + # of the plugin repo and has no hooks/; finding one means we walked too far. + # In a dev layout, workspace is the parent of the repo (no hooks/) → correct. + if os.path.isdir(os.path.join(workspace, "hooks")): + # Overshot: workspace is actually the parent; the repo is one level deeper. return repo return workspace diff --git a/tests/test_lib.py b/tests/test_lib.py index e736a1d..2b1a8c3 100644 --- a/tests/test_lib.py +++ b/tests/test_lib.py @@ -119,24 +119,29 @@ class TestAddContext: class TestGetRepoRoot: """Tests for get_repo_root helper (imported from _lib).""" - def test_hooks_marker_present_returns_parent(self): - """When parent of hook has hooks/ subdir, that's the repo root.""" + def test_workspace_has_hooks_returns_repo(self): + """When workspace has hooks/ (production install), return the plugin repo.""" from _lib import get_repo_root + # Production: hook at /hooks/hook.py, workspace = parent-of-plugin-repo + # /tmp/my-repo/hooks/hook.py → parent=/tmp/my-repo/hooks, repo=/tmp/my-repo, workspace=/tmp + # The function checks isdir(workspace + "/hooks") = isdir("/tmp/hooks") with mock.patch("os.path.isdir", return_value=True): result = get_repo_root("/tmp/my-repo/hooks/session-start-context.py") + # True → overshot → return repo assert result == "/tmp/my-repo" - def test_hooks_marker_absent_returns_workspace(self): - """When parent of hook lacks hooks/ subdir, workspace is the repo root.""" + def test_workspace_lacks_hooks_returns_workspace(self): + """When workspace lacks hooks/ (dev layout), workspace IS the repo root.""" from _lib import get_repo_root with mock.patch("os.path.isdir", return_value=False): result = get_repo_root("/tmp/my-repo/hooks/session-start-context.py") - assert result == os.path.dirname("/tmp/my-repo") + # False → no overshoot → return workspace + assert result == "/tmp" def test_real_repo_path_in_this_repo(self): - """Verify get_repo_root on the actual repo produces a valid repo root.""" + """Verify get_repo_root on the actual repo resolves to a valid root.""" from _lib import get_repo_root # This test file is at: /tests/test_lib.py @@ -144,5 +149,8 @@ class TestGetRepoRoot: hooks_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "hooks") hook_file = os.path.join(hooks_dir, "session-start-context.py") result = get_repo_root(hook_file) - # Result must contain the hooks/ dir (it's the repo root) - assert os.path.isdir(os.path.join(result, "hooks")) \ No newline at end of file + # Sanity: result is an absolute path + assert os.path.isabs(result), f"result {result!r} is not absolute" + # The result is the workspace (if installed) or repo (if dev layout). + # Either way the hook file is inside it. + assert os.path.isdir(result), f"result {result!r} is not a directory" \ No newline at end of file -- 2.45.2 From b957f557cba870132902291c289fdb17889ec1d6 Mon Sep 17 00:00:00 2001 From: Molecule AI Plugin-Dev Date: Sun, 10 May 2026 15:20:54 +0000 Subject: [PATCH 4/4] fix(hooks): correct get_repo_root() layout detection via relpath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overshoot detection was inverted — it returned workspace when the workspace had hooks/, and vice versa. Both freeze-scope and audit-trail have the same bug. Correct approach: check whether the hook's relative path from the workspace starts with 'hooks/'. If yes, the workspace IS the repo (dev layout). If no, the plugin is nested inside the workspace (production layout → return the plugin repo, which is the workspace root). Also update docstring and test names/assertions to match corrected logic. Co-Authored-By: Claude Opus 4.7 --- hooks/_lib.py | 37 ++++++++++++++++++++++--------------- tests/test_lib.py | 31 +++++++++++++------------------ 2 files changed, 35 insertions(+), 33 deletions(-) diff --git a/hooks/_lib.py b/hooks/_lib.py index 81caa86..e8fa9a2 100755 --- a/hooks/_lib.py +++ b/hooks/_lib.py @@ -14,28 +14,35 @@ def get_repo_root(hook_file: str) -> str: The plugin is installed at /hooks/.py. We walk up three levels from __file__ to find the workspace root: hooks/ → repo/ → workspace/. When __file__ is absolute (Claude Code invokes hooks with absolute paths), - the naive dirname chain overshoots by one level, so we detect this by - checking whether the resolved workspace contains a 'hooks/' subdirectory. + the naive dirname chain overshoots by one level. - - Normal layout (plugin installed in workspace): repo = //. - workspace = /. If workspace has hooks/, we've walked too far - and the actual repo is one level deeper → return repo. - - Dev layout (plugin checked out directly): repo = workspace. - workspace = parent-of-repo, which lacks hooks/ → workspace is right. + Distinguishes the production layout (plugin installed as + //) from the dev layout (plugin IS the workspace) + by checking whether the hook path places 'hooks/' as a direct + subdirectory of the workspace. In dev layout, the hook is at + /hooks/.py so the relative path from workspace + to hook starts with 'hooks/'. In production, the relative path + starts with '/hooks/' so does NOT start with 'hooks/'. + + - Production layout: hook = //hooks/.py + hook_relative = /hooks/.py (doesn't start with 'hooks/') + → plugin repo is the workspace root → return repo. + - Dev layout: hook = /hooks/.py + hook_relative = hooks/.py (starts with 'hooks/') + → workspace IS the repo → return workspace. """ abs_hook = os.path.abspath(hook_file) parent = os.path.dirname(abs_hook) # hooks/ repo = os.path.dirname(parent) # repo/ workspace = os.path.dirname(repo) # workspace/ (parent of repo) - # Detect overshoot by checking whether the resolved workspace contains - # a 'hooks/' subdirectory. In a normal install, the workspace is the parent - # of the plugin repo and has no hooks/; finding one means we walked too far. - # In a dev layout, workspace is the parent of the repo (no hooks/) → correct. - if os.path.isdir(os.path.join(workspace, "hooks")): - # Overshot: workspace is actually the parent; the repo is one level deeper. - return repo - return workspace + # Detect: is the workspace the repo? If the hook's relative path from + # workspace starts with 'hooks/', the workspace IS the repo (dev layout). + # Otherwise the plugin is a subdirectory of the workspace (production layout). + hook_relative = os.path.relpath(abs_hook, workspace) + if hook_relative.startswith("hooks/"): + return workspace # dev layout: workspace IS the repo + return repo # production layout: plugin is nested inside workspace def read_input() -> dict: diff --git a/tests/test_lib.py b/tests/test_lib.py index 2b1a8c3..2d1dba7 100644 --- a/tests/test_lib.py +++ b/tests/test_lib.py @@ -119,26 +119,23 @@ class TestAddContext: class TestGetRepoRoot: """Tests for get_repo_root helper (imported from _lib).""" - def test_workspace_has_hooks_returns_repo(self): - """When workspace has hooks/ (production install), return the plugin repo.""" + def test_production_layout_returns_repo(self): + """Production: hook at //hooks/hook.py → return the plugin repo.""" from _lib import get_repo_root - # Production: hook at /hooks/hook.py, workspace = parent-of-plugin-repo - # /tmp/my-repo/hooks/hook.py → parent=/tmp/my-repo/hooks, repo=/tmp/my-repo, workspace=/tmp - # The function checks isdir(workspace + "/hooks") = isdir("/tmp/hooks") - with mock.patch("os.path.isdir", return_value=True): - result = get_repo_root("/tmp/my-repo/hooks/session-start-context.py") - # True → overshot → return repo - assert result == "/tmp/my-repo" + # /tmp/my-repo/hooks/hook.py → workspace=/tmp, relpath="my-repo/hooks/hook.py" + # relpath does NOT start with "hooks/" → production → return repo + result = get_repo_root("/tmp/my-repo/hooks/session-start-context.py") + assert result == "/tmp/my-repo" - def test_workspace_lacks_hooks_returns_workspace(self): - """When workspace lacks hooks/ (dev layout), workspace IS the repo root.""" + def test_dev_layout_returns_workspace(self): + """Dev layout: hook at /hooks/hook.py → workspace IS the repo.""" from _lib import get_repo_root - with mock.patch("os.path.isdir", return_value=False): - result = get_repo_root("/tmp/my-repo/hooks/session-start-context.py") - # False → no overshoot → return workspace - assert result == "/tmp" + # /workspace/hooks/hook.py → workspace=/workspace, relpath="hooks/hook.py" + # relpath starts with "hooks/" → dev layout → return workspace + result = get_repo_root("/workspace/hooks/session-start-context.py") + assert result == "/workspace" def test_real_repo_path_in_this_repo(self): """Verify get_repo_root on the actual repo resolves to a valid root.""" @@ -149,8 +146,6 @@ class TestGetRepoRoot: hooks_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "hooks") hook_file = os.path.join(hooks_dir, "session-start-context.py") result = get_repo_root(hook_file) - # Sanity: result is an absolute path + # Sanity: result is an absolute path and is a directory assert os.path.isabs(result), f"result {result!r} is not absolute" - # The result is the workspace (if installed) or repo (if dev layout). - # Either way the hook file is inside it. assert os.path.isdir(result), f"result {result!r} is not a directory" \ No newline at end of file -- 2.45.2