Skip to content
Open
10 changes: 10 additions & 0 deletions plugins/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ your project's `.claude/settings.json`. Copy
}
```

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`).

Expand Down
46 changes: 37 additions & 9 deletions plugins/claude-code/hooks/pre-compact.sh
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,45 @@ 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 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/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 = {}
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("~")
sources = [(home, ("settings.json",))]
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:
block = _read_block(os.path.join(d, ".claude", name))
if block is not None:
merged.update(block)
return merged


Expand Down
59 changes: 43 additions & 16 deletions plugins/claude-code/hooks/session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -76,25 +76,52 @@ 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): 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 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
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("~")
sources = [(home, ("settings.json",))]
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:
block = _read_block(os.path.join(d, ".claude", name))
if block is not None:
found = True
merged.update(block)
return merged, found


Expand Down
6 changes: 4 additions & 2 deletions plugins/claude-code/skills/bm-remember/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion plugins/claude-code/skills/bm-share/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<project-ref>` → `{ "promoteFolder": "shared" }`.
These are the allowed share targets. `<project-ref>` is a workspace-qualified
name (e.g. `my-team-2/notes`) or an `external_id` UUID.
Expand Down
6 changes: 4 additions & 2 deletions plugins/claude-code/skills/bm-status/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down