diff --git a/justfile b/justfile index 13842d03d..da1da1c12 100644 --- a/justfile +++ b/justfile @@ -706,6 +706,8 @@ release version: .claude-plugin/marketplace.json \ plugins/claude-code/.claude-plugin/plugin.json \ plugins/claude-code/.claude-plugin/marketplace.json \ + plugins/claude-code/hooks/session_start.py \ + plugins/claude-code/hooks/pre_compact.py \ plugins/codex/.codex-plugin/plugin.json \ plugins/codex/hooks/session_start.py \ plugins/codex/hooks/pre_compact.py \ @@ -826,6 +828,8 @@ beta version: .claude-plugin/marketplace.json \ plugins/claude-code/.claude-plugin/plugin.json \ plugins/claude-code/.claude-plugin/marketplace.json \ + plugins/claude-code/hooks/session_start.py \ + plugins/claude-code/hooks/pre_compact.py \ plugins/codex/.codex-plugin/plugin.json \ plugins/codex/hooks/session_start.py \ plugins/codex/hooks/pre_compact.py \ diff --git a/plugins/claude-code/hooks/pre_compact.py b/plugins/claude-code/hooks/pre_compact.py index bdb002fd4..69c550cea 100755 --- a/plugins/claude-code/hooks/pre_compact.py +++ b/plugins/claude-code/hooks/pre_compact.py @@ -1,7 +1,14 @@ #!/usr/bin/env -S uv run --quiet --script # /// script # requires-python = ">=3.12" -# dependencies = ["basic-memory>=0.22.1"] +# dependencies = [ +# "basic-memory>=0.23.0", +# # Direct pre-release pin so old uv resolves basic-memory (which +# # requires this exact beta transitively) without enabling +# # pre-releases for basic-memory itself. Keep in lockstep with +# # core pyproject's fastmcp pin. +# "fastmcp==4.0.0b1", +# ] # /// """PreCompact hook — the entire hook. All logic (settings resolution, the extractive checkpoint note, lifecycle-envelope capture) lives in the released diff --git a/plugins/claude-code/hooks/session_start.py b/plugins/claude-code/hooks/session_start.py index 2dc789223..d3c9203fc 100755 --- a/plugins/claude-code/hooks/session_start.py +++ b/plugins/claude-code/hooks/session_start.py @@ -1,7 +1,14 @@ #!/usr/bin/env -S uv run --quiet --script # /// script # requires-python = ">=3.12" -# dependencies = ["basic-memory>=0.22.1"] +# dependencies = [ +# "basic-memory>=0.23.0", +# # Direct pre-release pin so old uv resolves basic-memory (which +# # requires this exact beta transitively) without enabling +# # pre-releases for basic-memory itself. Keep in lockstep with +# # core pyproject's fastmcp pin. +# "fastmcp==4.0.0b1", +# ] # /// """SessionStart hook — the entire hook. All logic (settings resolution, the context brief, lifecycle-envelope capture) lives in the released basic-memory diff --git a/plugins/claude-code/hooks/test_claude_pre_compact.py b/plugins/claude-code/hooks/test_claude_pre_compact.py index 6c44ceec6..ec2cb62c2 100644 --- a/plugins/claude-code/hooks/test_claude_pre_compact.py +++ b/plugins/claude-code/hooks/test_claude_pre_compact.py @@ -119,7 +119,7 @@ def test_dependency_floor_matches_package_version() -> None: # scripts/update_versions.py bumps this line at release; drift between the # script floor and the package version fails here, before a release lands. text = SCRIPT.read_text(encoding="utf-8") - floors = re.findall(r'^# dependencies = \["basic-memory>=([^"]+)"\]$', text, re.MULTILINE) + floors = re.findall(r'^# "basic-memory>=([^"]+)",$', text, re.MULTILINE) assert floors == [__version__] diff --git a/plugins/claude-code/hooks/test_claude_session_start.py b/plugins/claude-code/hooks/test_claude_session_start.py index 8d5c9110e..460c19efc 100644 --- a/plugins/claude-code/hooks/test_claude_session_start.py +++ b/plugins/claude-code/hooks/test_claude_session_start.py @@ -119,7 +119,7 @@ def test_dependency_floor_matches_package_version() -> None: # scripts/update_versions.py bumps this line at release; drift between the # script floor and the package version fails here, before a release lands. text = SCRIPT.read_text(encoding="utf-8") - floors = re.findall(r'^# dependencies = \["basic-memory>=([^"]+)"\]$', text, re.MULTILINE) + floors = re.findall(r'^# "basic-memory>=([^"]+)",$', text, re.MULTILINE) assert floors == [__version__] diff --git a/plugins/codex/hooks/pre_compact.py b/plugins/codex/hooks/pre_compact.py index 06a1f490d..d4fb4de88 100755 --- a/plugins/codex/hooks/pre_compact.py +++ b/plugins/codex/hooks/pre_compact.py @@ -3,6 +3,10 @@ # requires-python = ">=3.12" # dependencies = [ # "basic-memory @ git+https://github.com/basicmachines-co/basic-memory@v0.23.0", +# # Direct pre-release pin so old uv resolves basic-memory (which +# # requires this exact beta transitively) without enabling +# # pre-releases broadly. Keep in lockstep with core pyproject. +# "fastmcp==4.0.0b1", # ] # /// """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 ac7d94c9c..a7b0e271a 100755 --- a/plugins/codex/hooks/session_start.py +++ b/plugins/codex/hooks/session_start.py @@ -3,6 +3,10 @@ # requires-python = ">=3.12" # dependencies = [ # "basic-memory @ git+https://github.com/basicmachines-co/basic-memory@v0.23.0", +# # Direct pre-release pin so old uv resolves basic-memory (which +# # requires this exact beta transitively) without enabling +# # pre-releases broadly. Keep in lockstep with core pyproject. +# "fastmcp==4.0.0b1", # ] # /// """SessionStart hook launcher backed by a pinned Basic Memory revision. diff --git a/scripts/update_versions.py b/scripts/update_versions.py index e3e3d4cb0..3cff5a982 100644 --- a/scripts/update_versions.py +++ b/scripts/update_versions.py @@ -153,8 +153,8 @@ def _update_packages(version: str, *, dry_run: bool) -> None: for script in HOOK_SCRIPTS: update_text( script, - r'^# dependencies = \["basic-memory>=[^"]+"\]$', - f'# dependencies = ["basic-memory>={version}"]', + r'^# "basic-memory>=[^"]+",$', + f'# "basic-memory>={version}",', dry_run=dry_run, ) update_text( diff --git a/scripts/validate_claude_plugin.py b/scripts/validate_claude_plugin.py index e8fe10059..0af19e7ad 100644 --- a/scripts/validate_claude_plugin.py +++ b/scripts/validate_claude_plugin.py @@ -137,6 +137,20 @@ def validate_claude_plugin(plugin_dir: Path) -> None: for event in REQUIRED_HOOK_EVENTS: if event not in hooks: raise SystemExit(f"hooks/hooks.json: missing {event} hook") + # Trigger: the shims pin core's exact fastmcp beta so older uv can resolve + # basic-memory (pre-release transitives are refused there) without + # enabling pre-releases for basic-memory itself. + # Why: nothing else keeps the shim pin and core's pyproject in lockstep, + # and drift makes shim resolution conflict — which the fail-open contract + # turns into a silent hook no-op. + # Outcome: validation fails loudly when core moves its fastmcp pin without + # the shims following. + core_pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8") + core_match = re.search(r'^\s*"fastmcp==([^"]+)",$', core_pyproject, re.MULTILINE) + if not core_match: + raise SystemExit("pyproject.toml: no exact fastmcp pin found") + core_fastmcp_pin = core_match.group(1) + for rel in REQUIRED_HOOK_SCRIPTS: script = plugin_dir / rel if not script.exists(): @@ -145,9 +159,17 @@ def validate_claude_plugin(plugin_dir: Path) -> None: raise SystemExit(f"Hook script is not executable: {script}") text = script.read_text(encoding="utf-8") if "# /// script" not in text or not re.search( - r'^# dependencies = \["basic-memory>=[^"]+"\]$', text, re.MULTILINE + r'^# "basic-memory>=[^"]+",$', text, re.MULTILINE ): raise SystemExit(f"Hook script missing PEP 723 basic-memory floor: {script}") + shim_pins = re.findall(r'^# "fastmcp==([^"]+)",$', text, re.MULTILINE) + if len(shim_pins) != 1: + raise SystemExit(f"Hook script missing exact fastmcp pin: {script}") + if shim_pins[0] != core_fastmcp_pin: + raise SystemExit( + f"{script}: fastmcp pin {shim_pins[0]} does not match " + f"core pyproject pin {core_fastmcp_pin}" + ) # --- Output style --- output_style = plugin_dir / "output-styles/basic-memory.md" diff --git a/tests/test_claude_plugin_hooks.py b/tests/test_claude_plugin_hooks.py index 2c3fea411..552bd173c 100644 --- a/tests/test_claude_plugin_hooks.py +++ b/tests/test_claude_plugin_hooks.py @@ -80,7 +80,7 @@ def test_claude_script_floor_matches_released_version(event: str, script_name: s # Release drift between the PEP 723 floor and the package version fails # here (and in each script's co-located test) before a release lands. text = (REPO_ROOT / "plugins/claude-code/hooks" / script_name).read_text(encoding="utf-8") - floors = re.findall(r'^# dependencies = \["basic-memory>=([^"]+)"\]$', text, re.MULTILINE) + floors = re.findall(r'^# "basic-memory>=([^"]+)",$', text, re.MULTILINE) assert floors == [CURRENT_VERSION] diff --git a/tests/test_codex_plugin_package.py b/tests/test_codex_plugin_package.py index 8e2eb3d82..4c9f5b32c 100644 --- a/tests/test_codex_plugin_package.py +++ b/tests/test_codex_plugin_package.py @@ -386,3 +386,21 @@ def test_pr_create_skill_delegates_to_current_pr_workflow() -> None: assert "never merges" in skill assert "Do not enable auto-merge" in skill assert "current-head gate" in skill + + +def test_codex_hook_fastmcp_pins_match_core_pyproject() -> None: + """The shims pin core's exact fastmcp beta; drift breaks shim resolution. + + Old uv refuses pre-release transitives, so each shim carries the pin as a + direct dependency. Nothing rewrites it automatically — this test is the + lockstep enforcement when core's pyproject moves its fastmcp pin. + """ + repo_root = Path(__file__).resolve().parents[1] + core = re.search( + r'^\s*"fastmcp==([^"]+)",$', (repo_root / "pyproject.toml").read_text(), re.MULTILINE + ) + assert core is not None + for name in ("session_start.py", "pre_compact.py"): + shim = (repo_root / "plugins/codex/hooks" / name).read_text() + pins = re.findall(r'^# "fastmcp==([^"]+)",$', shim, re.MULTILINE) + assert pins == [core.group(1)], name diff --git a/tests/test_update_versions.py b/tests/test_update_versions.py index d0fbd07c3..06b3cbda9 100644 --- a/tests/test_update_versions.py +++ b/tests/test_update_versions.py @@ -35,7 +35,10 @@ def test_parse_version_preserves_python_prerelease_for_non_npm_manifests() -> No "#!/usr/bin/env -S uv run --quiet --script\n" "# /// script\n" '# requires-python = ">=3.12"\n' - '# dependencies = ["basic-memory>=0.0.0"]\n' + "# dependencies = [\n" + '# "basic-memory>=0.0.0",\n' + '# "fastmcp==4.0.0b1",\n' + "# ]\n" "# ///\n" # Mirrors the real scripts: the docstring mentions a launcher spelling # without a version spec, which the anchored updater pattern must skip.