diff --git a/plugins/codex/README.md b/plugins/codex/README.md index 809e99e56..8d51a9836 100644 --- a/plugins/codex/README.md +++ b/plugins/codex/README.md @@ -38,7 +38,7 @@ verification, decision capture, and resumable checkpoints. | `skills/` | Codex-native Basic Memory workflows | | `schemas/` | Seed schemas for Codex sessions, decisions, and tasks | -The hook scripts carry no logic: the brief, the checkpoint, and opt-in event +The hook scripts carry no logic: the brief, the checkpoint, and lifecycle-event capture all live in the pinned Basic Memory revision behind `bm hook`. Each is a self-contained PEP 723 script pinned to a Basic Memory Git ref. Both refs are updated together with `just set-codex-hook-version `. @@ -69,9 +69,12 @@ Plugin installation is user-level in Codex, so one install makes the plugin available across projects on the same machine. Start a new Codex thread after installing so Codex can load the plugin skills, MCP configuration, and hooks. -Each repository still needs its own `.codex/basic-memory.json` so the plugin -knows which Basic Memory project and folders to use for that checkout. Run the -setup skill in each repo, or create the config file shown below. +Configuration can live at user level in `~/.codex/basic-memory.json` or at +project level in `.codex/basic-memory.json`. User-level settings are the base; +the nearest project file overrides only the keys it declares. `redactKeys` and +`redactPaths` are the privacy exception: their user and project lists accumulate. +The setup skill asks which scope to use and recommends user-level configuration +by default. To customize how Codex writes memory, edit `skills/bm-writing/SKILL.md` in the plugin source. `bm-checkpoint`, `bm-decide`, and `bm-remember` all apply that @@ -79,7 +82,7 @@ shared skill while retaining their own schemas and evidence requirements. ## Configuration -Run the setup skill, or create `.codex/basic-memory.json` in a repo: +Run the setup skill, or create `~/.codex/basic-memory.json` for shared defaults: ```json { @@ -88,24 +91,36 @@ Run the setup skill, or create `.codex/basic-memory.json` in a repo: "secondaryProjects": [], "teamProjects": {}, "focus": "code/dev", - "sessionProfile": "coding", - "repository": "owner/repo", - "captureFolder": "codex", "rememberFolder": "codex-remember", "recallTimeframe": "7d", - "captureEvents": false, + "captureEvents": true, "redactKeys": [], "redactPaths": [], - "placementConventions": "Put decisions in decisions/ and work checkpoints in codex/." + "placementConventions": "Put decisions in decisions/ and work checkpoints in codex//." } } ``` -`captureEvents` is opt-in and off by default: only the JSON boolean `true` -enables recording of redacted lifecycle-event envelopes to a local inbox under -your Basic Memory home (`basic-memory hook status` / `basic-memory hook flush`). -Add `redactKeys` and `redactPaths` arrays to extend the built-in redaction floor -for repository-specific payload fields and paths. +Codex event capture is on by default. Set the JSON boolean `false` at user or +project level to opt out; malformed values fail closed. Captured, redacted +lifecycle-event envelopes land in a local inbox under your Basic Memory home +(`basic-memory hook status` / `basic-memory hook flush`). Add `redactKeys` and +`redactPaths` arrays to extend the built-in redaction floor. + +When `captureFolder` is omitted, Codex resolves the Git top-level directory and +writes to `codex/`. An explicit folder still wins. + +For a coding profile, keep both the profile and checkout-specific repository +identifier in the project file without duplicating the shared settings: + +```json +{ + "basicMemory": { + "sessionProfile": "coding", + "repository": "owner/repo" + } +} +``` The plugin's seed schemas cover notes Codex writes directly: `codex_session`, `coding_session`, `decision`, and `task`. Coding sessions require structured diff --git a/plugins/codex/hooks/pre_compact.py b/plugins/codex/hooks/pre_compact.py index e972e810e..f5cdd90cb 100755 --- a/plugins/codex/hooks/pre_compact.py +++ b/plugins/codex/hooks/pre_compact.py @@ -2,7 +2,7 @@ # /// script # requires-python = ">=3.12" # dependencies = [ -# "basic-memory @ git+https://github.com/basicmachines-co/basic-memory@6e9f2fcf5f00f3577d38a3d8b6e2f2baca3079f7", +# "basic-memory @ git+https://github.com/basicmachines-co/basic-memory@bd5d145d5be3bab3c73291ae2a698f8e5a1e54cb", # ] # /// """PreCompact hook launcher backed by a pinned Basic Memory revision. diff --git a/plugins/codex/hooks/session_start.py b/plugins/codex/hooks/session_start.py index 72968bfb3..426bf4dc8 100755 --- a/plugins/codex/hooks/session_start.py +++ b/plugins/codex/hooks/session_start.py @@ -2,7 +2,7 @@ # /// script # requires-python = ">=3.12" # dependencies = [ -# "basic-memory @ git+https://github.com/basicmachines-co/basic-memory@6e9f2fcf5f00f3577d38a3d8b6e2f2baca3079f7", +# "basic-memory @ git+https://github.com/basicmachines-co/basic-memory@bd5d145d5be3bab3c73291ae2a698f8e5a1e54cb", # ] # /// """SessionStart hook launcher backed by a pinned Basic Memory revision. diff --git a/plugins/codex/skills/bm-checkpoint/SKILL.md b/plugins/codex/skills/bm-checkpoint/SKILL.md index 67c408f50..246b744a7 100644 --- a/plugins/codex/skills/bm-checkpoint/SKILL.md +++ b/plugins/codex/skills/bm-checkpoint/SKILL.md @@ -11,10 +11,11 @@ context transition. ## Gather -Read `.codex/basic-memory.json` if present: +Read `~/.codex/basic-memory.json`, then the nearest project +`.codex/basic-memory.json`; project keys override user keys: - `primaryProject`, default omitted -- `captureFolder`, default `codex` +- `captureFolder`, default `codex/` - `placementConventions`, optional - `sessionProfile`, default `general` - `repository`, required when `sessionProfile` is `coding` diff --git a/plugins/codex/skills/bm-decide/SKILL.md b/plugins/codex/skills/bm-decide/SKILL.md index fcf1e9148..5922b8cc9 100644 --- a/plugins/codex/skills/bm-decide/SKILL.md +++ b/plugins/codex/skills/bm-decide/SKILL.md @@ -10,7 +10,8 @@ choice with rationale and consequences, not a casual preference. ## Steps -1. Resolve `.codex/basic-memory.json`: +1. Resolve `~/.codex/basic-memory.json`, then the nearest project + `.codex/basic-memory.json`; project keys override user keys: - write to `primaryProject` when set - follow `placementConventions` for the directory when they are specific - otherwise use `decisions` diff --git a/plugins/codex/skills/bm-orient/SKILL.md b/plugins/codex/skills/bm-orient/SKILL.md index ea005b5ab..393745912 100644 --- a/plugins/codex/skills/bm-orient/SKILL.md +++ b/plugins/codex/skills/bm-orient/SKILL.md @@ -10,7 +10,8 @@ the user asks where things stand. ## Steps -1. Read `.codex/basic-memory.json` if present. Use `primaryProject`, `secondaryProjects`, +1. Read `~/.codex/basic-memory.json`, then the nearest project + `.codex/basic-memory.json`; project keys override user keys. Use `primaryProject`, `secondaryProjects`, `recallTimeframe`, `sessionProfile`, `repository`, and `placementConventions`. If the file is missing, continue against the default Basic Memory project and mention that setup has not been run. diff --git a/plugins/codex/skills/bm-remember/SKILL.md b/plugins/codex/skills/bm-remember/SKILL.md index c98b28a80..0f31be422 100644 --- a/plugins/codex/skills/bm-remember/SKILL.md +++ b/plugins/codex/skills/bm-remember/SKILL.md @@ -10,7 +10,8 @@ a small fact that should survive the current thread. ## Steps -1. Read `.codex/basic-memory.json` if present: +1. Read `~/.codex/basic-memory.json`, then the nearest project + `.codex/basic-memory.json`; project keys override user keys: - `primaryProject`, default omitted - `rememberFolder`, default `codex-remember` diff --git a/plugins/codex/skills/bm-setup/SKILL.md b/plugins/codex/skills/bm-setup/SKILL.md index 008c8da3a..4f4b8b191 100644 --- a/plugins/codex/skills/bm-setup/SKILL.md +++ b/plugins/codex/skills/bm-setup/SKILL.md @@ -1,6 +1,6 @@ --- name: bm-setup -description: Set up Basic Memory for Codex in the current repo by mapping a Basic Memory project, seeding schemas, and writing .codex/basic-memory.json. +description: Set up Basic Memory for Codex at user or project level by mapping a Basic Memory project and seeding schemas. --- # Basic Memory for Codex Setup @@ -25,6 +25,10 @@ Confirm Basic Memory is reachable before changing files: Ask the user to choose the project mapping. Do not infer write targets from the repo, default project, current directory, or previous local state. +- config level: user-level `~/.codex/basic-memory.json` or project-level + `.codex/basic-memory.json`. Ask explicitly and recommend user level by default. + Project settings override user settings key by key, except `redactKeys` and + `redactPaths`, which accumulate so project config cannot weaken user privacy. - storage mode: cloud, local, or mixed. Prefer the user's stated mode over any CLI default. - `focus`: code/dev, research, writing, planning, or mixed. @@ -35,13 +39,13 @@ repo, default project, current directory, or previous local state. - `primaryProject`: an existing Basic Memory project or a new one to create. - `secondaryProjects`: optional read-only projects for session-start context. - `teamProjects`: optional share targets for `bm-share`. -- `captureFolder`: default `codex`. +- `captureFolder`: default `codex/`, derived from the Git top-level + directory. Ask only when the user wants an explicit override. - `rememberFolder`: default `codex-remember`. - `placementConventions`: a short note about where decisions, tasks, and research notes should land. - `captureEvents`: whether to record redacted lifecycle-event envelopes in the - local hook inbox. Default to `false`; SessionStart briefs and PreCompact - checkpoints work without it. + local hook inbox. Default to `true`; an explicit JSON boolean `false` opts out. - `redactKeys` and `redactPaths`: optional additions to the built-in redaction floor. Ask for these only when event capture is enabled or the user has repo-specific privacy requirements. @@ -55,7 +59,8 @@ and optional pull-request metadata in Basic Memory. Explain the capture tradeoff before asking: enabled capture adds a local, redacted event trail that stays queued until `bm hook flush` projects it. It does -not write to team projects, and only the JSON boolean `true` enables it. +not write to team projects. The default is enabled; an explicit JSON boolean +`false` disables it, and malformed values fail closed. If there are duplicate names, show qualified names and ask the user which one to use. Prefer qualified project names or project ids for cloud projects. Never pick @@ -67,7 +72,8 @@ summarizing the real convention. ## Apply -After confirming the plan, write `.codex/basic-memory.json` in the repo: +After confirming the plan, write the shared settings to the chosen user-level or +project-level file: ```json { @@ -77,12 +83,10 @@ After confirming the plan, write `.codex/basic-memory.json` in the repo: "projectMode": "cloud", "teamProjects": {}, "focus": "", - "sessionProfile": "coding", - "repository": "owner/name", - "captureFolder": "codex", + "sessionProfile": "", "rememberFolder": "codex-remember", "recallTimeframe": "7d", - "captureEvents": false, + "captureEvents": true, "redactKeys": [], "redactPaths": [], "placementConventions": "" @@ -90,18 +94,37 @@ After confirming the plan, write `.codex/basic-memory.json` in the repo: } ``` -Preserve unrelated keys if the file already exists. Include `projectMode` when -the user chose cloud, local, or mixed routing. Always persist `captureEvents` as -a JSON boolean. Empty `redactKeys` and `redactPaths` lists may be omitted; when -present, they must be JSON arrays of strings. `redactKeys` extends payload-key -redaction, while `redactPaths` also protects working-directory and path-bearing -checkpoint content. This file is intentionally Codex-specific; do not write -`.claude/settings.json`. - -Persist `sessionProfile` explicitly. Persist `repository` only for the `coding` -profile, after the user confirms it. A coding setup is incomplete without a -repository identifier because the `coding_session` schema requires queryable Git -identity fields. +Omit `captureFolder` to use `codex/`; persist it only for an explicit +override. Preserve unrelated keys if the chosen file already exists. Include +`projectMode` when the user chose cloud, local, or mixed routing. Always persist +`captureEvents` as a JSON boolean. Empty `redactKeys` and `redactPaths` lists may +be omitted; when present, they must be JSON arrays of strings. `redactKeys` +extends payload-key redaction, while `redactPaths` also protects +working-directory and path-bearing checkpoint content. User and project +redaction lists accumulate. These files are intentionally Codex-specific; do +not write `.claude/settings.json`. + +For a user-level coding setup, omit `sessionProfile` from the shared user file and +keep both the coding profile and confirmed repository identifier in the project +file so neither can affect other repositories: + +```json +{ + "basicMemory": { + "sessionProfile": "coding", + "repository": "owner/name" + } +} +``` + +For a project-level setup, add `repository` to the shared settings in that same +project file. + +Persist `sessionProfile` explicitly in the chosen file, except for a user-level +coding setup where it belongs in the project file alongside `repository`. Persist +`repository` only for the `coding` profile, after the user confirms it. A coding +setup is incomplete without a repository identifier because the `coding_session` +schema requires queryable Git identity fields. ## Seed Schemas diff --git a/plugins/codex/skills/bm-share/SKILL.md b/plugins/codex/skills/bm-share/SKILL.md index 8ebc3f782..4166efeb7 100644 --- a/plugins/codex/skills/bm-share/SKILL.md +++ b/plugins/codex/skills/bm-share/SKILL.md @@ -11,7 +11,8 @@ stay personal. ## Steps -1. Read `.codex/basic-memory.json` and resolve: +1. Read `~/.codex/basic-memory.json`, then the nearest project + `.codex/basic-memory.json`; project keys override user keys. Resolve: - `primaryProject` - `teamProjects`, a map of project ref to settings diff --git a/plugins/codex/skills/bm-status/SKILL.md b/plugins/codex/skills/bm-status/SKILL.md index 074d85b0e..a70eb5593 100644 --- a/plugins/codex/skills/bm-status/SKILL.md +++ b/plugins/codex/skills/bm-status/SKILL.md @@ -19,8 +19,10 @@ Gather a concise diagnostic. Do not over-investigate. claiming the hooks cannot work. 2. Plugin config: - - read `.codex/basic-memory.json` - - report `primaryProject`, `secondaryProjects`, `teamProjects`, + - read `~/.codex/basic-memory.json`, then the nearest project + `.codex/basic-memory.json`; project keys override user keys, while + `redactKeys` and `redactPaths` accumulate + - report the resolved `primaryProject`, `secondaryProjects`, `teamProjects`, `captureFolder`, `rememberFolder`, `recallTimeframe`, `focus`, `sessionProfile`, `repository`, `captureEvents`, `redactKeys`, and `redactPaths` diff --git a/scripts/validate_codex_plugin.py b/scripts/validate_codex_plugin.py index 5d502ef01..d8bc55803 100755 --- a/scripts/validate_codex_plugin.py +++ b/scripts/validate_codex_plugin.py @@ -26,6 +26,9 @@ REQUIRED_SKILL_TEXT: dict[str, tuple[str, ...]] = { "bm-setup": ( "captureEvents", + "user-level", + "project-level", + "codex/", "redactKeys", "redactPaths", "sessionProfile", @@ -33,6 +36,7 @@ "hook status --harness codex", ), "bm-status": ( + "~/.codex/basic-memory.json", "hook status --harness codex", "pending envelopes", "processed envelopes", diff --git a/src/basic_memory/cli/commands/hook.py b/src/basic_memory/cli/commands/hook.py index 5b512ad14..d6ab13e9b 100644 --- a/src/basic_memory/cli/commands/hook.py +++ b/src/basic_memory/cli/commands/hook.py @@ -3,14 +3,14 @@ Harness plugins reduce to manifests plus one-line shims that exec ``bm hook --harness claude|codex`` with the hook JSON on stdin. All logic lives here: per-harness stdin adapters, the session-start context brief, -the pre-compact checkpoint note, opt-in envelope capture into the inbox WAL, +the pre-compact checkpoint note, lifecycle-event capture into the inbox WAL, and the flush/status operator surface. Contracts: - Harness verbs (session-start, pre-compact) are fail-open: any error logs to stderr and exits 0 — a hook must never disrupt an agent session. - - The capture gate is fail-closed: ``captureEvents`` must be the JSON - boolean ``true``; strings never enable recording. + - Codex event capture defaults on. An explicit JSON boolean ``false`` turns + it off, while malformed values and malformed config fail closed. - Graph-derived brief content is fenced and labeled as reference data, not instructions — the prompt-injection boundary. @@ -18,8 +18,9 @@ (ported here; the plugin hooks are now zero-logic shims that exec these verbs): the ``basicMemory`` block of ``.claude/settings.json`` / ``.claude/settings.local.json`` (nearest ancestor, over the user-level -``~/.claude/settings.json``) for Claude, and ``.codex/basic-memory.json`` for -Codex. ``install`` / ``remove`` wire the same verbs into the user-level +``~/.claude/settings.json``) for Claude, and the nearest project +``.codex/basic-memory.json`` over ``~/.codex/basic-memory.json`` for Codex. +``install`` / ``remove`` wire the same verbs into the user-level harness config for standalone (non-marketplace) users, ownership-tagged so removal is surgical. """ @@ -71,6 +72,7 @@ class Harness(str, Enum): # Cap how many shared projects we read per session — bounds latency and output. MAX_SHARED = 6 CODING_SESSION_PROFILE = "coding" +CODEX_DEFAULT_CAPTURE_EVENTS = True @dataclass(frozen=True) @@ -227,23 +229,102 @@ def load_claude_settings(directory: Path) -> tuple[dict, bool]: return merged, found -def load_codex_settings(directory: Path) -> tuple[dict, bool]: - """Read the Codex config file, mirroring the codex hook scripts. - - A present-but-broken file still counts as configured (found=True) so the - user sees the status hint instead of the first-run nudge. - """ - path = directory / ".codex" / "basic-memory.json" +def _read_codex_block(path: Path) -> tuple[dict | None, bool]: + """Read one Codex settings block and preserve malformed-file presence.""" try: data = json.loads(path.read_text(encoding="utf-8")) except FileNotFoundError: - return {}, False + return None, False except (OSError, json.JSONDecodeError): - return {}, True + return None, True if not isinstance(data, dict): - return {}, True + return None, True block = data.get("basicMemory", data) - return (block if isinstance(block, dict) else {}), True + return (block if isinstance(block, dict) else None), True + + +def _codex_project_dir(directory: Path) -> Path: + """Nearest ancestor with a project Codex config, excluding user fallback.""" + current = directory.resolve() + while True: + if (current / ".codex" / "basic-memory.json").is_file(): + return current + if current.parent == current: + return directory.resolve() + current = current.parent + + +def _git_value(directory: Path, *args: str) -> str | None: + """Read one optional Git value without turning config defaults into failures.""" + try: + result = subprocess.run( + ["git", *args], + cwd=directory, + capture_output=True, + text=True, + timeout=5, + ) + except (OSError, subprocess.SubprocessError): + return None + value = result.stdout.strip() + return value if result.returncode == 0 and value else None + + +def _codex_default_capture_folder(directory: Path) -> str: + """Namespace the default checkpoint folder by the current repository directory.""" + repo_root = _git_value(directory, "rev-parse", "--show-toplevel") + if repo_root is None: + return PROFILES[Harness.codex].default_capture_folder + repo_dir = Path(repo_root).name.strip() + if not repo_dir: + return PROFILES[Harness.codex].default_capture_folder + return f"codex/{repo_dir}" + + +def load_codex_settings(directory: Path) -> tuple[dict, bool]: + """Merge user and project Codex settings, then resolve checkout defaults. + + Precedence (lowest to highest): ``~/.codex/basic-memory.json``, then the + nearest project ``.codex/basic-memory.json``. Redaction lists accumulate so + a project cannot weaken user-level privacy rules. Codex capture is enabled + when omitted, and its default folder is namespaced by the Git repository + directory. Any malformed source counts as configured and fails closed for + the whole evaluation so a later source cannot rebuild routing without the + missing redaction policy. + """ + defaults: dict = { + "captureEvents": CODEX_DEFAULT_CAPTURE_EVENTS, + "captureFolder": _codex_default_capture_folder(directory), + } + merged = dict(defaults) + found = False + home = Path.home() + sources = [home / ".codex" / "basic-memory.json"] + project = _codex_project_dir(directory) + project_path = project / ".codex" / "basic-memory.json" + if project_path != sources[0]: + sources.append(project_path) + + for path in sources: + block, present = _read_codex_block(path) + if not present: + continue + found = True + if block is None: + # Trigger: any configured source exists but cannot be trusted. + # Why: continuing could combine a later checkpoint route with a + # missing earlier redaction policy and persist unredacted data. + # Outcome: discard every route and disable capture for this event. + return {**defaults, "captureEvents": False}, True + cumulative_redactions: dict[str, list[str]] = {} + for key in ("redactKeys", "redactPaths"): + values = [*_string_list(merged.get(key)), *_string_list(block.get(key))] + if values: + cumulative_redactions[key] = list(dict.fromkeys(values)) + merged.update(block) + merged.update(cumulative_redactions) + + return merged, found def load_harness_settings(harness: Harness, directory: Path) -> tuple[dict, bool]: diff --git a/tests/cli/test_hook_command.py b/tests/cli/test_hook_command.py index dec697b37..d914e9180 100644 --- a/tests/cli/test_hook_command.py +++ b/tests/cli/test_hook_command.py @@ -89,6 +89,10 @@ def _transcript(tmp_path: Path) -> Path: return path +def _init_git_repo(project: Path) -> None: + subprocess.run(["git", "init"], cwd=project, check=True, capture_output=True) + + def _inbox_envelopes(bm_home: Path) -> list[dict]: inbox_dir = bm_home / "inbox" return [ @@ -461,6 +465,28 @@ def test_capture_events_true_boolean_writes_envelope(bm_home: Path, claude_proje assert envelope["payload"]["capture_folder"] == "sessions" +def test_codex_capture_events_defaults_on(bm_home: Path, tmp_path: Path) -> None: + project = tmp_path / "codex-proj" + (project / ".codex").mkdir(parents=True) + (project / ".codex" / "basic-memory.json").write_text( + json.dumps({"basicMemory": {"primaryProject": "demo"}}), encoding="utf-8" + ) + with patch( + "basic_memory.mcp.tools.search_notes", new_callable=AsyncMock, return_value=SEARCH_EMPTY + ): + result = runner.invoke( + cli_app, + ["hook", "session-start", "--harness", "codex", "--project-dir", str(project)], + input=_payload(project, source="startup"), + ) + + assert result.exit_code == 0 + envelopes = _inbox_envelopes(bm_home) + assert len(envelopes) == 1 + assert envelopes[0]["source"] == "codex" + assert envelopes[0]["payload"]["capture_folder"] == "codex" + + @pytest.mark.parametrize("gate_value", ["true", "false", 1, "yes", {"on": True}]) def test_capture_events_fails_closed_on_non_boolean( bm_home: Path, claude_project: Path, gate_value @@ -716,6 +742,7 @@ def test_pre_compact_surfaces_write_error_on_stderr( def test_pre_compact_codex_includes_workspace_sections(bm_home: Path, tmp_path: Path) -> None: project = tmp_path / "codex-proj" (project / ".codex").mkdir(parents=True) + _init_git_repo(project) (project / ".codex" / "basic-memory.json").write_text( json.dumps({"primaryProject": "demo"}), encoding="utf-8", # flat form, no basicMemory key @@ -741,7 +768,7 @@ def test_pre_compact_codex_includes_workspace_sections(bm_home: Path, tmp_path: assert result.exit_code == 0 assert mock_write.await_args is not None kwargs = mock_write.await_args.kwargs - assert kwargs["directory"] == "codex" + assert kwargs["directory"] == "codex/codex-proj" assert kwargs["tags"] == ["codex", "auto-capture"] assert kwargs["title"].startswith("Codex session ") assert kwargs["note_type"] == "codex_session" @@ -820,6 +847,76 @@ def test_pre_compact_codex_skips_working_tree_when_workspace_denied( assert kwargs["metadata"]["cwd"] == "[REDACTED_PATH]" +def test_pre_compact_codex_malformed_project_does_not_use_user_checkpoint_route( + bm_home: Path, tmp_path: Path +) -> None: + home = Path.home() + (home / ".codex").mkdir(parents=True, exist_ok=True) + (home / ".codex" / "basic-memory.json").write_text( + json.dumps( + { + "basicMemory": { + "primaryProject": "user-wide", + "captureEvents": True, + "redactPaths": ["/shared/private"], + } + } + ), + encoding="utf-8", + ) + project = tmp_path / "codex-proj" + (project / ".codex").mkdir(parents=True) + (project / ".codex" / "basic-memory.json").write_text("{broken", encoding="utf-8") + transcript = _transcript(tmp_path) + mock_write = AsyncMock() + + with patch("basic_memory.mcp.tools.write_note", mock_write): + result = runner.invoke( + cli_app, + ["hook", "pre-compact", "--harness", "codex", "--project-dir", str(project)], + input=_payload(project, transcript_path=str(transcript), trigger="auto"), + ) + + assert result.exit_code == 0 + assert not (bm_home / "inbox").exists() + mock_write.assert_not_awaited() + + +def test_pre_compact_codex_malformed_user_blocks_project_checkpoint_route( + bm_home: Path, tmp_path: Path +) -> None: + home = Path.home() + (home / ".codex").mkdir(parents=True, exist_ok=True) + (home / ".codex" / "basic-memory.json").write_text("{broken", encoding="utf-8") + project = tmp_path / "codex-proj" + (project / ".codex").mkdir(parents=True) + (project / ".codex" / "basic-memory.json").write_text( + json.dumps( + { + "basicMemory": { + "primaryProject": "project-level", + "captureEvents": True, + "redactPaths": ["/project/private"], + } + } + ), + encoding="utf-8", + ) + transcript = _transcript(tmp_path) + mock_write = AsyncMock() + + with patch("basic_memory.mcp.tools.write_note", mock_write): + result = runner.invoke( + cli_app, + ["hook", "pre-compact", "--harness", "codex", "--project-dir", str(project)], + input=_payload(project, transcript_path=str(transcript), trigger="auto"), + ) + + assert result.exit_code == 0 + assert not (bm_home / "inbox").exists() + mock_write.assert_not_awaited() + + # --- Fail-open contract --- @@ -1631,7 +1728,57 @@ def test_codex_settings_broken_file_counts_as_configured(tmp_path: Path) -> None merged, found = hook_module.load_codex_settings(project) - assert merged == {} + assert merged == {"captureEvents": False, "captureFolder": "codex"} + assert found is True + + +def test_codex_settings_malformed_project_invalidates_user_fallback(tmp_path: Path) -> None: + home = Path.home() + (home / ".codex").mkdir(parents=True, exist_ok=True) + (home / ".codex" / "basic-memory.json").write_text( + json.dumps( + { + "basicMemory": { + "primaryProject": "user-wide", + "captureEvents": True, + "redactPaths": ["/shared/private"], + } + } + ), + encoding="utf-8", + ) + project = tmp_path / "proj" + (project / ".codex").mkdir(parents=True) + (project / ".codex" / "basic-memory.json").write_text("{broken", encoding="utf-8") + + merged, found = hook_module.load_codex_settings(project) + + assert merged == {"captureEvents": False, "captureFolder": "codex"} + assert found is True + + +def test_codex_settings_malformed_user_blocks_project_fallback(tmp_path: Path) -> None: + home = Path.home() + (home / ".codex").mkdir(parents=True, exist_ok=True) + (home / ".codex" / "basic-memory.json").write_text("{broken", encoding="utf-8") + project = tmp_path / "proj" + (project / ".codex").mkdir(parents=True) + (project / ".codex" / "basic-memory.json").write_text( + json.dumps( + { + "basicMemory": { + "primaryProject": "project-level", + "captureEvents": True, + "redactPaths": ["/project/private"], + } + } + ), + encoding="utf-8", + ) + + merged, found = hook_module.load_codex_settings(project) + + assert merged == {"captureEvents": False, "captureFolder": "codex"} assert found is True @@ -1640,7 +1787,10 @@ def test_codex_settings_non_dict_document(tmp_path: Path) -> None: (project / ".codex").mkdir(parents=True) (project / ".codex" / "basic-memory.json").write_text("[1]", encoding="utf-8") - assert hook_module.load_codex_settings(project) == ({}, True) + assert hook_module.load_codex_settings(project) == ( + {"captureEvents": False, "captureFolder": "codex"}, + True, + ) def test_codex_settings_non_dict_basic_memory_block(tmp_path: Path) -> None: @@ -1650,7 +1800,91 @@ def test_codex_settings_non_dict_basic_memory_block(tmp_path: Path) -> None: json.dumps({"basicMemory": 42}), encoding="utf-8" ) - assert hook_module.load_codex_settings(project) == ({}, True) + assert hook_module.load_codex_settings(project) == ( + {"captureEvents": False, "captureFolder": "codex"}, + True, + ) + + +def test_codex_settings_default_on_without_config(tmp_path: Path) -> None: + project = tmp_path / "bare" + project.mkdir() + + assert hook_module.load_codex_settings(project) == ( + {"captureEvents": True, "captureFolder": "codex"}, + False, + ) + + +def test_codex_settings_merge_user_then_project_with_checkout_folder(tmp_path: Path) -> None: + home = Path.home() + (home / ".codex").mkdir(parents=True, exist_ok=True) + (home / ".codex" / "basic-memory.json").write_text( + json.dumps( + { + "basicMemory": { + "primaryProject": "user-wide", + "recallTimeframe": "9d", + "captureEvents": True, + "redactKeys": ["token", "shared-secret"], + "redactPaths": ["/shared/private"], + } + } + ), + encoding="utf-8", + ) + project = tmp_path / "widgets" + (project / ".codex").mkdir(parents=True) + _init_git_repo(project) + (project / ".codex" / "basic-memory.json").write_text( + json.dumps( + { + "basicMemory": { + "primaryProject": "project-level", + "redactKeys": ["token", "repo-secret"], + "redactPaths": [], + } + } + ), + encoding="utf-8", + ) + + merged, found = hook_module.load_codex_settings(project) + + assert found is True + assert merged["primaryProject"] == "project-level" + assert merged["recallTimeframe"] == "9d" + assert merged["captureEvents"] is True + assert merged["captureFolder"] == "codex/widgets" + assert merged["redactKeys"] == ["token", "shared-secret", "repo-secret"] + assert merged["redactPaths"] == ["/shared/private"] + + +def test_codex_project_settings_override_user_capture_defaults(tmp_path: Path) -> None: + home = Path.home() + (home / ".codex").mkdir(parents=True, exist_ok=True) + (home / ".codex" / "basic-memory.json").write_text( + json.dumps({"basicMemory": {"captureEvents": True}}), encoding="utf-8" + ) + project = tmp_path / "proj" + (project / ".codex").mkdir(parents=True) + (project / ".codex" / "basic-memory.json").write_text( + json.dumps( + { + "basicMemory": { + "captureEvents": False, + "captureFolder": "private/checkpoints", + } + } + ), + encoding="utf-8", + ) + + merged, found = hook_module.load_codex_settings(project) + + assert found is True + assert merged["captureEvents"] is False + assert merged["captureFolder"] == "private/checkpoints" def test_string_list_guards_config_types() -> None: diff --git a/tests/test_codex_plugin_package.py b/tests/test_codex_plugin_package.py index 0f4431eb7..ecbc546ed 100644 --- a/tests/test_codex_plugin_package.py +++ b/tests/test_codex_plugin_package.py @@ -79,7 +79,27 @@ def test_codex_plugin_docs_explain_global_install_and_repo_mapping() -> None: assert 'codex plugin marketplace add "$(git rev-parse --show-toplevel)"' in readme assert "codex plugin add codex@basic-memory" in readme assert "Plugin installation is user-level in Codex" in readme - assert "Each repository still needs its own `.codex/basic-memory.json`" in readme + assert "Configuration can live at user level in `~/.codex/basic-memory.json`" in readme + assert "the nearest project file overrides only the keys it declares" in readme + assert "keep both the profile and checkout-specific repository" in readme + + +def test_user_level_coding_profile_stays_with_repository_override() -> None: + repo_root = Path(__file__).resolve().parents[1] + readme = (repo_root / "plugins/codex/README.md").read_text(encoding="utf-8") + setup = (repo_root / "plugins/codex/skills/bm-setup/SKILL.md").read_text(encoding="utf-8") + + readme_blocks = re.findall(r"```json\n(.*?)\n```", readme, flags=re.DOTALL) + shared_settings = json.loads(readme_blocks[0])["basicMemory"] + project_settings = json.loads(readme_blocks[1])["basicMemory"] + + assert "sessionProfile" not in shared_settings + assert project_settings == { + "sessionProfile": "coding", + "repository": "owner/repo", + } + assert "omit `sessionProfile` from the shared user file" in setup + assert '"sessionProfile": "coding",\n "repository": "owner/name"' in setup def test_coding_session_schema_is_shared_across_host_plugins() -> None: