From aa454ee9ea11a59ab856e58487aefd0945144a47 Mon Sep 17 00:00:00 2001 From: Samuel Lawrentz Date: Wed, 10 Jun 2026 01:00:51 +0530 Subject: [PATCH 1/5] feat(plugins): fall back to user-level ~/.claude settings for hook config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SessionStart and PreCompact hooks read basicMemory config only from /.claude/settings.json, so every project needs its own block (or a bm-setup run) before briefs resolve a pinned project and checkpoints write at all. Users with one primary knowledge graph end up repeating the same config in every repo. Make load_settings in both hooks merge user-level ~/.claude/settings.json and settings.local.json as the base, with the project's .claude files overriding per key — same layering Claude Code itself uses for settings. A single user-level basicMemory block now covers every project, while any project can still pin its own mapping, which wins. Documented in the plugin README. Co-Authored-By: Claude Fable 5 Signed-off-by: Samuel Lawrentz --- plugins/claude-code/README.md | 7 ++++ plugins/claude-code/hooks/pre-compact.sh | 23 ++++++++----- plugins/claude-code/hooks/session-start.sh | 39 +++++++++++++--------- 3 files changed, 44 insertions(+), 25 deletions(-) diff --git a/plugins/claude-code/README.md b/plugins/claude-code/README.md index d2f4a5e61..7d932e346 100644 --- a/plugins/claude-code/README.md +++ b/plugins/claude-code/README.md @@ -80,6 +80,13 @@ your project's `.claude/settings.json`. Copy } ``` +The block can also live in your **user-level** `~/.claude/settings.json` (or +`settings.local.json`) — one block there covers every project, no per-repo setup. +Precedence, lowest to highest: user-level `settings.json` → user-level +`settings.local.json` → project `settings.json` → project `settings.local.json`, +merged per key — so a project that pins its own `primaryProject` wins over the +user-level default. + To enable the capture reflexes, also set `"outputStyle": "basic-memory"` in your settings (or select it via `/config`). diff --git a/plugins/claude-code/hooks/pre-compact.sh b/plugins/claude-code/hooks/pre-compact.sh index ee1340469..6675b5123 100755 --- a/plugins/claude-code/hooks/pre-compact.sh +++ b/plugins/claude-code/hooks/pre-compact.sh @@ -65,16 +65,21 @@ session_id = payload.get("session_id") or "" def load_settings(directory): + # Same precedence as session-start.sh: user-level ~/.claude is the base, + # the project's .claude overrides it, settings.local.json wins per level. merged = {} - for name in ("settings.json", "settings.local.json"): - path = os.path.join(directory, ".claude", name) - try: - with open(path) as fh: - merged.update(json.load(fh).get("basicMemory") or {}) - except FileNotFoundError: - continue - except Exception: - continue + home = os.path.expanduser("~") + dirs = [home] if os.path.abspath(directory) == home else [home, directory] + for d in dirs: + for name in ("settings.json", "settings.local.json"): + path = os.path.join(d, ".claude", name) + try: + with open(path) as fh: + merged.update(json.load(fh).get("basicMemory") or {}) + except FileNotFoundError: + continue + except Exception: + continue return merged diff --git a/plugins/claude-code/hooks/session-start.sh b/plugins/claude-code/hooks/session-start.sh index ec8311d5b..4a3fa4838 100755 --- a/plugins/claude-code/hooks/session-start.sh +++ b/plugins/claude-code/hooks/session-start.sh @@ -76,25 +76,32 @@ cwd = payload.get("cwd") or os.getcwd() # --- Load plugin config from .claude settings (local overrides committed) --- -# Precedence: settings.local.json (per-user) wins over settings.json (team). -# `found` is True if either file declared a basicMemory block at all — its -# presence is the first-run sentinel (setup writing it stops the nudge below). +# Precedence (lowest to highest): user-level ~/.claude/settings.json, +# ~/.claude/settings.local.json, then the project's .claude/settings.json and +# .claude/settings.local.json. A single user-level basicMemory block can cover +# every project without running setup per repo; any project can still pin its +# own mapping, which wins. `found` is True if any file declared a basicMemory +# block at all — its presence is the first-run sentinel (setup writing it stops +# the nudge below). def load_settings(directory): merged = {} found = False - for name in ("settings.json", "settings.local.json"): - path = os.path.join(directory, ".claude", name) - try: - with open(path) as fh: - data = json.load(fh) - except FileNotFoundError: - continue - except Exception: - continue - block = data.get("basicMemory") - if isinstance(block, dict): - found = True - merged.update(block) + home = os.path.expanduser("~") + dirs = [home] if os.path.abspath(directory) == home else [home, directory] + for d in dirs: + for name in ("settings.json", "settings.local.json"): + path = os.path.join(d, ".claude", name) + try: + with open(path) as fh: + data = json.load(fh) + except FileNotFoundError: + continue + except Exception: + continue + block = data.get("basicMemory") + if isinstance(block, dict): + found = True + merged.update(block) return merged, found From da548f17371300a2121719f6244d3dc2e5789640 Mon Sep 17 00:00:00 2001 From: Samuel Lawrentz Date: Wed, 10 Jun 2026 09:02:22 +0530 Subject: [PATCH 2/5] fix(plugins): address Codex review on user-level settings fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - User-level pass reads only ~/.claude/settings.json (no user-level settings.local.json — it isn't a real Claude Code source), so a stale global local file can't silently reroute the brief/checkpoint. - Resolve project settings from the nearest .claude dir at or above cwd, so a repo-root mapping wins when Claude starts in a subdirectory. - bm-remember / bm-share / bm-status skills now read the same precedence (user-level base, project overrides), matching the README claim that one user-level block covers every command. - README: drop user-level settings.local.json from the precedence note and document the ancestor-walk behaviour. Co-Authored-By: Claude Fable 5 Signed-off-by: Samuel Lawrentz --- plugins/claude-code/README.md | 15 +++-- plugins/claude-code/hooks/pre-compact.sh | 51 +++++++++++---- plugins/claude-code/hooks/session-start.sh | 62 +++++++++++++------ .../claude-code/skills/bm-remember/SKILL.md | 6 +- plugins/claude-code/skills/bm-share/SKILL.md | 4 +- plugins/claude-code/skills/bm-status/SKILL.md | 6 +- 6 files changed, 100 insertions(+), 44 deletions(-) diff --git a/plugins/claude-code/README.md b/plugins/claude-code/README.md index 7d932e346..f8108c3ce 100644 --- a/plugins/claude-code/README.md +++ b/plugins/claude-code/README.md @@ -80,12 +80,15 @@ your project's `.claude/settings.json`. Copy } ``` -The block can also live in your **user-level** `~/.claude/settings.json` (or -`settings.local.json`) — one block there covers every project, no per-repo setup. -Precedence, lowest to highest: user-level `settings.json` → user-level -`settings.local.json` → project `settings.json` → project `settings.local.json`, -merged per key — so a project that pins its own `primaryProject` wins over the -user-level default. +The block can also live in your **user-level** `~/.claude/settings.json` — one +block there covers every project, no per-repo setup. Precedence, lowest to +highest: user-level `settings.json` → project `settings.json` → project +`settings.local.json`, merged per key — so a project that pins its own +`primaryProject` wins over the user-level default. (This mirrors Claude Code's +own sources: `settings.local.json` is project-scoped only, so there is no +user-level `settings.local.json`.) The hooks resolve the project settings from +the nearest `.claude` directory at or above the working directory, so a mapping +in the repo root still applies when Claude starts in a subdirectory. To enable the capture reflexes, also set `"outputStyle": "basic-memory"` in your settings (or select it via `/config`). diff --git a/plugins/claude-code/hooks/pre-compact.sh b/plugins/claude-code/hooks/pre-compact.sh index 6675b5123..d6cc14afb 100755 --- a/plugins/claude-code/hooks/pre-compact.sh +++ b/plugins/claude-code/hooks/pre-compact.sh @@ -64,22 +64,47 @@ transcript_path = payload.get("transcript_path") or "" session_id = payload.get("session_id") or "" +def _read_block(path): + try: + with open(path) as fh: + block = json.load(fh).get("basicMemory") + except FileNotFoundError: + return None + except Exception: + return None + return block if isinstance(block, dict) else None + + +def _project_dir(directory): + # Nearest ancestor (including directory) holding a .claude settings file. + d = os.path.abspath(directory) + while True: + for name in ("settings.json", "settings.local.json"): + if os.path.isfile(os.path.join(d, ".claude", name)): + return d + parent = os.path.dirname(d) + if parent == d: + return os.path.abspath(directory) + d = parent + + def load_settings(directory): - # Same precedence as session-start.sh: user-level ~/.claude is the base, - # the project's .claude overrides it, settings.local.json wins per level. + # Same precedence as session-start.sh: user-level ~/.claude/settings.json is + # the base (no user-level settings.local.json — it isn't a real Claude Code + # source), then the nearest project .claude (settings.json, then + # settings.local.json) overrides it. cwd may be a repo subdirectory, so walk + # ancestors to the project root rather than reading cwd alone. merged = {} home = os.path.expanduser("~") - dirs = [home] if os.path.abspath(directory) == home else [home, directory] - for d in dirs: - for name in ("settings.json", "settings.local.json"): - path = os.path.join(d, ".claude", name) - try: - with open(path) as fh: - merged.update(json.load(fh).get("basicMemory") or {}) - except FileNotFoundError: - continue - except Exception: - continue + sources = [(home, ("settings.json",))] + project = _project_dir(directory) + if os.path.abspath(project) != home: + sources.append((project, ("settings.json", "settings.local.json"))) + for d, names in sources: + for name in names: + block = _read_block(os.path.join(d, ".claude", name)) + if block is not None: + merged.update(block) return merged diff --git a/plugins/claude-code/hooks/session-start.sh b/plugins/claude-code/hooks/session-start.sh index 4a3fa4838..fc4ec6605 100755 --- a/plugins/claude-code/hooks/session-start.sh +++ b/plugins/claude-code/hooks/session-start.sh @@ -76,30 +76,52 @@ cwd = payload.get("cwd") or os.getcwd() # --- Load plugin config from .claude settings (local overrides committed) --- -# Precedence (lowest to highest): user-level ~/.claude/settings.json, -# ~/.claude/settings.local.json, then the project's .claude/settings.json and -# .claude/settings.local.json. A single user-level basicMemory block can cover -# every project without running setup per repo; any project can still pin its -# own mapping, which wins. `found` is True if any file declared a basicMemory -# block at all — its presence is the first-run sentinel (setup writing it stops -# the nudge below). +# Precedence (lowest to highest): the user-level ~/.claude/settings.json, then +# the project's .claude/settings.json and .claude/settings.local.json. A single +# user-level basicMemory block can cover every project without running setup per +# repo; any project can still pin its own mapping, which wins. We mirror Claude +# Code's real sources: user level is settings.json only (there is no user-level +# settings.local.json), local settings are project-scoped. Because the hook cwd +# can be a repo subdirectory, we walk ancestors to the nearest .claude config so +# a project-root mapping is honoured instead of skipped. `found` is True if any +# file declared a basicMemory block — its presence is the first-run sentinel +# (setup writing it stops the nudge below). +def _read_block(path): + try: + with open(path) as fh: + block = json.load(fh).get("basicMemory") + except FileNotFoundError: + return None + except Exception: + return None + return block if isinstance(block, dict) else None + + +def _project_dir(directory): + # Nearest ancestor (including directory) holding a .claude settings file. + d = os.path.abspath(directory) + while True: + for name in ("settings.json", "settings.local.json"): + if os.path.isfile(os.path.join(d, ".claude", name)): + return d + parent = os.path.dirname(d) + if parent == d: + return os.path.abspath(directory) + d = parent + + def load_settings(directory): merged = {} found = False home = os.path.expanduser("~") - dirs = [home] if os.path.abspath(directory) == home else [home, directory] - for d in dirs: - for name in ("settings.json", "settings.local.json"): - path = os.path.join(d, ".claude", name) - try: - with open(path) as fh: - data = json.load(fh) - except FileNotFoundError: - continue - except Exception: - continue - block = data.get("basicMemory") - if isinstance(block, dict): + sources = [(home, ("settings.json",))] + project = _project_dir(directory) + if os.path.abspath(project) != home: + sources.append((project, ("settings.json", "settings.local.json"))) + for d, names in sources: + for name in names: + block = _read_block(os.path.join(d, ".claude", name)) + if block is not None: found = True merged.update(block) return merged, found diff --git a/plugins/claude-code/skills/bm-remember/SKILL.md b/plugins/claude-code/skills/bm-remember/SKILL.md index 68b5327d4..f3b7a3c7d 100644 --- a/plugins/claude-code/skills/bm-remember/SKILL.md +++ b/plugins/claude-code/skills/bm-remember/SKILL.md @@ -10,8 +10,10 @@ Capture `$ARGUMENTS` into Basic Memory as a quick note, keeping the user's words ## Steps -1. **Resolve config.** Read `.claude/settings.json` (and `.claude/settings.local.json` - if it exists) and look for the `basicMemory` block: +1. **Resolve config.** Read the `basicMemory` block with the same precedence the + hooks use: user-level `~/.claude/settings.json` as the base, then the project's + `.claude/settings.json` and `.claude/settings.local.json` override it per key. A + user-level block alone is enough; a project can still pin its own values: - `rememberFolder` — folder for quick captures (default: `bm-remember`) - `primaryProject` — project to write to (default: omit the `project` argument so Basic Memory uses its default project) diff --git a/plugins/claude-code/skills/bm-share/SKILL.md b/plugins/claude-code/skills/bm-share/SKILL.md index 7ecbb0d87..d49be56f5 100644 --- a/plugins/claude-code/skills/bm-share/SKILL.md +++ b/plugins/claude-code/skills/bm-share/SKILL.md @@ -12,7 +12,9 @@ project — session checkpoints and `/basic-memory:bm-remember` always stay pers ## Steps -1. **Resolve config.** Read `.claude/settings.json` (+ `.local`) `basicMemory`: +1. **Resolve config.** Read the `basicMemory` block with the hooks' precedence: + user-level `~/.claude/settings.json` as the base, then the project's + `.claude/settings.json` and `.claude/settings.local.json` override per key: - `teamProjects` — a map of `` → `{ "promoteFolder": "shared" }`. These are the allowed share targets. `` is a workspace-qualified name (e.g. `my-team-2/notes`) or an `external_id` UUID. diff --git a/plugins/claude-code/skills/bm-status/SKILL.md b/plugins/claude-code/skills/bm-status/SKILL.md index 23c99b335..dc5a668e5 100644 --- a/plugins/claude-code/skills/bm-status/SKILL.md +++ b/plugins/claude-code/skills/bm-status/SKILL.md @@ -15,8 +15,10 @@ This is a quick diagnostic — gather the facts and lay them out; don't over-inv neither is found, report that Basic Memory isn't installed or on PATH, and stop — nothing else will work without it. -2. **Configuration.** Read `.claude/settings.json` (and `.claude/settings.local.json` - if present) and report: +2. **Configuration.** Read the `basicMemory` block with the hooks' precedence — + user-level `~/.claude/settings.json` as the base, then the project's + `.claude/settings.json` and `.claude/settings.local.json` overriding per key — + and report (note when a value comes from the user-level block vs. the project): - From the `basicMemory` block: `primaryProject` (or note none is pinned — the default project is used), `secondaryProjects` (team/shared read sources), `teamProjects` (share targets for `/basic-memory:bm-share`), `captureFolder` From 6c0b27f54ff6258a809881fef735cfa0bf7bd7b7 Mon Sep 17 00:00:00 2001 From: Samuel Lawrentz Date: Wed, 10 Jun 2026 09:06:21 +0530 Subject: [PATCH 3/5] refactor(plugins): simplify settings resolution helpers - Drop redundant except FileNotFoundError in _read_block (subclass of Exception, already caught). - Drop redundant os.path.abspath in load_settings; _project_dir already returns an absolute path. Co-Authored-By: Claude Fable 5 Signed-off-by: Samuel Lawrentz --- plugins/claude-code/hooks/pre-compact.sh | 6 ++---- plugins/claude-code/hooks/session-start.sh | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/plugins/claude-code/hooks/pre-compact.sh b/plugins/claude-code/hooks/pre-compact.sh index d6cc14afb..0482e3f53 100755 --- a/plugins/claude-code/hooks/pre-compact.sh +++ b/plugins/claude-code/hooks/pre-compact.sh @@ -68,8 +68,6 @@ def _read_block(path): try: with open(path) as fh: block = json.load(fh).get("basicMemory") - except FileNotFoundError: - return None except Exception: return None return block if isinstance(block, dict) else None @@ -97,8 +95,8 @@ def load_settings(directory): merged = {} home = os.path.expanduser("~") sources = [(home, ("settings.json",))] - project = _project_dir(directory) - if os.path.abspath(project) != home: + project = _project_dir(directory) # already absolute + if project != home: sources.append((project, ("settings.json", "settings.local.json"))) for d, names in sources: for name in names: diff --git a/plugins/claude-code/hooks/session-start.sh b/plugins/claude-code/hooks/session-start.sh index fc4ec6605..f3c00f9bc 100755 --- a/plugins/claude-code/hooks/session-start.sh +++ b/plugins/claude-code/hooks/session-start.sh @@ -90,8 +90,6 @@ def _read_block(path): try: with open(path) as fh: block = json.load(fh).get("basicMemory") - except FileNotFoundError: - return None except Exception: return None return block if isinstance(block, dict) else None @@ -115,8 +113,8 @@ def load_settings(directory): found = False home = os.path.expanduser("~") sources = [(home, ("settings.json",))] - project = _project_dir(directory) - if os.path.abspath(project) != home: + project = _project_dir(directory) # already absolute + if project != home: sources.append((project, ("settings.json", "settings.local.json"))) for d, names in sources: for name in names: From d28d731b3fca976b3341d90e9e2297af18d978d1 Mon Sep 17 00:00:00 2001 From: Samuel Lawrentz Date: Fri, 10 Jul 2026 23:41:35 +0530 Subject: [PATCH 4/5] docs(plugins): clarify hooks resolve nearest ancestor with a .claude settings file Signed-off-by: Samuel Lawrentz --- plugins/claude-code/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/claude-code/README.md b/plugins/claude-code/README.md index f8108c3ce..4891481cc 100644 --- a/plugins/claude-code/README.md +++ b/plugins/claude-code/README.md @@ -87,8 +87,9 @@ highest: user-level `settings.json` → project `settings.json` → project `primaryProject` wins over the user-level default. (This mirrors Claude Code's own sources: `settings.local.json` is project-scoped only, so there is no user-level `settings.local.json`.) The hooks resolve the project settings from -the nearest `.claude` directory at or above the working directory, so a mapping -in the repo root still applies when Claude starts in a subdirectory. +the nearest ancestor directory (including the working directory) whose `.claude` +folder contains a `settings.json` or `settings.local.json`, so a mapping in the +repo root still applies when Claude starts in a subdirectory. To enable the capture reflexes, also set `"outputStyle": "basic-memory"` in your settings (or select it via `/config`). From ed1de8d81d1c033f8e476e778aa8631b3b9ae9de Mon Sep 17 00:00:00 2001 From: phernandez Date: Tue, 14 Jul 2026 21:29:01 -0500 Subject: [PATCH 5/5] test(plugins): cover Claude hook settings precedence Signed-off-by: phernandez --- tests/test_claude_plugin_hooks.py | 201 ++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 tests/test_claude_plugin_hooks.py diff --git a/tests/test_claude_plugin_hooks.py b/tests/test_claude_plugin_hooks.py new file mode 100644 index 000000000..74d404c5d --- /dev/null +++ b/tests/test_claude_plugin_hooks.py @@ -0,0 +1,201 @@ +import json +import os +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path + +import pytest + + +HOOK_RUNTIME_AVAILABLE = shutil.which("bash") is not None and shutil.which("python3") is not None +pytestmark = pytest.mark.skipif( + not HOOK_RUNTIME_AVAILABLE, + reason="Claude Code hook tests require bash and python3", +) + + +@dataclass(frozen=True, slots=True) +class HookHarness: + repo_root: Path + home: Path + bin_dir: Path + command_log: Path + + def write_settings(self, directory: Path, name: str, basic_memory: dict[str, object]) -> None: + settings_dir = directory / ".claude" + settings_dir.mkdir(parents=True, exist_ok=True) + (settings_dir / name).write_text( + json.dumps({"basicMemory": basic_memory}), + encoding="utf-8", + ) + + def run_hook(self, hook_name: str, payload: dict[str, str]) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env.update( + { + "BM_TEST_COMMAND_LOG": str(self.command_log), + "HOME": str(self.home), + "PATH": f"{self.bin_dir}{os.pathsep}{env['PATH']}", + } + ) + return subprocess.run( + ["bash", str(self.repo_root / "plugins/claude-code/hooks" / hook_name)], + input=json.dumps(payload), + capture_output=True, + check=False, + env=env, + text=True, + ) + + def logged_commands(self) -> list[list[str]]: + if not self.command_log.exists(): + return [] + return [json.loads(line) for line in self.command_log.read_text().splitlines()] + + +@pytest.fixture +def hook_harness(tmp_path: Path) -> HookHarness: + repo_root = Path(__file__).resolve().parents[1] + home = tmp_path / "home" + bin_dir = tmp_path / "bin" + home.mkdir() + bin_dir.mkdir() + command_log = tmp_path / "basic-memory-commands.jsonl" + + fake_basic_memory = bin_dir / "basic-memory" + fake_basic_memory.write_text( + """#!/usr/bin/env python3 +import json +import os +import sys + +with open(os.environ["BM_TEST_COMMAND_LOG"], "a", encoding="utf-8") as command_log: + command_log.write(json.dumps(sys.argv[1:]) + "\\n") + +if sys.argv[1:3] == ["tool", "search-notes"]: + print(json.dumps({"results": []})) +""", + encoding="utf-8", + ) + fake_basic_memory.chmod(0o755) + + return HookHarness( + repo_root=repo_root, + home=home, + bin_dir=bin_dir, + command_log=command_log, + ) + + +def test_session_start_uses_user_settings_without_project_config( + hook_harness: HookHarness, +) -> None: + hook_harness.write_settings( + hook_harness.home, + "settings.json", + {"primaryProject": "global-project", "captureFolder": "global-sessions"}, + ) + # Claude Code does not treat this as a user-level settings source. A stale + # file must not silently reroute hooks away from the visible global config. + hook_harness.write_settings( + hook_harness.home, + "settings.local.json", + {"primaryProject": "stale-project"}, + ) + cwd = hook_harness.home / "work/repo/src" + cwd.mkdir(parents=True) + + result = hook_harness.run_hook("session-start.sh", {"cwd": str(cwd)}) + + assert result.returncode == 0, result.stderr + assert "**Project:** global-project" in result.stdout + assert "`global-sessions/`" in result.stdout + assert "stale-project" not in result.stdout + + +def test_session_start_merges_nearest_ancestor_project_settings_over_user_settings( + hook_harness: HookHarness, +) -> None: + hook_harness.write_settings( + hook_harness.home, + "settings.json", + {"primaryProject": "global-project", "captureFolder": "global-sessions"}, + ) + project_root = hook_harness.home / "work/repo" + hook_harness.write_settings( + project_root, + "settings.json", + {"primaryProject": "project-override"}, + ) + hook_harness.write_settings( + project_root, + "settings.local.json", + {"captureFolder": "local-sessions"}, + ) + cwd = project_root / "packages/client/src" + cwd.mkdir(parents=True) + + result = hook_harness.run_hook("session-start.sh", {"cwd": str(cwd)}) + + assert result.returncode == 0, result.stderr + assert "**Project:** project-override" in result.stdout + assert "`local-sessions/`" in result.stdout + search_commands = [ + command + for command in hook_harness.logged_commands() + if command[:2] == ["tool", "search-notes"] + ] + assert len(search_commands) == 3 + assert all( + command[command.index("--project") + 1] == "project-override" for command in search_commands + ) + + +def test_pre_compact_uses_merged_project_and_capture_folder( + hook_harness: HookHarness, + tmp_path: Path, +) -> None: + hook_harness.write_settings( + hook_harness.home, + "settings.json", + {"primaryProject": "global-project", "captureFolder": "global-sessions"}, + ) + project_root = hook_harness.home / "work/repo" + hook_harness.write_settings( + project_root, + "settings.json", + {"primaryProject": "project-override"}, + ) + hook_harness.write_settings( + project_root, + "settings.local.json", + {"captureFolder": "local-sessions"}, + ) + cwd = project_root / "src" + cwd.mkdir(parents=True) + transcript = tmp_path / "transcript.jsonl" + transcript.write_text( + json.dumps({"message": {"role": "user", "content": "Ship the settings fallback"}}) + "\n", + encoding="utf-8", + ) + + result = hook_harness.run_hook( + "pre-compact.sh", + { + "cwd": str(cwd), + "session_id": "session-123", + "transcript_path": str(transcript), + }, + ) + + assert result.returncode == 0, result.stderr + write_commands = [ + command + for command in hook_harness.logged_commands() + if command[:2] == ["tool", "write-note"] + ] + assert len(write_commands) == 1 + write_command = write_commands[0] + assert write_command[write_command.index("--project") + 1] == "project-override" + assert write_command[write_command.index("--folder") + 1] == "local-sessions"