diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e138b728..470c3161 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -59,6 +59,12 @@ "description": "Hypothesis-first experiment workflow โ€” scaffolding, eval packs, results, verdicts, and the two-commit discipline", "source": "./plugins/experiments", "category": "development" + }, + { + "name": "plugin-doctor", + "description": "Warns when installed agentic-primitives plugins have updates available, checked weekly. Never auto-updates.", + "source": "./plugins/plugin-doctor", + "category": "observability" } ] } diff --git a/.gitignore b/.gitignore index 386f13f8..8a8abf55 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ build/ dist/ target/ +# Local git worktrees +.worktrees/ + # Python __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 45659fa3..bbfcd2a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### ๐Ÿฉบ plugin-doctor + +A new plugin that warns when installed `agentic-primitives` plugins have updates available. + +#### Added + +- **`plugins/plugin-doctor/`** โ€” `SessionStart` hook compares installed plugin versions (from `~/.claude/plugins/cache/agentic-primitives/`) against the marketplace catalog (from `~/.claude/plugins/marketplaces/agentic-primitives/`), refreshing the catalog at most once a week. Emits `additionalContext` naming outdated plugins when found; instructs Claude to ask the user before updating anything. Never runs `claude plugin update` itself. +- **37 unit/integration tests** in `tests/unit/claude/hooks/test_plugin_doctor.py` covering state I/O, cadence gating, semver comparison, and the handler end-to-end via subprocess. + ### ๐Ÿ— Workspace Injection Contract (ADR-035) A small, cross-orchestrator file-injection seam that any consumer of the workspace image (agentic-domain-runner, Syntropic137, future Codex/Gemini wrappers) can target. diff --git a/README.md b/README.md index 3ae260d0..57f6bfd7 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ Replace `sdlc` with any plugin name from the [Available Plugins](#available-plug | **observability** | `claude plugin install observability@agentic-primitives --scope user` | Full-spectrum JSONL event observability | | **delegation** | `claude plugin install delegation@agentic-primitives --scope user` | Delegating work โ€” `claude -p`, Codex, and session handoffs | | **experiments** | `claude plugin install experiments@agentic-primitives --scope user` | Hypothesis-first experiment workflow | +| **plugin-doctor** | `claude plugin install plugin-doctor@agentic-primitives --scope user` | Warns when installed plugins are outdated | ### What's in each plugin @@ -168,6 +169,7 @@ Replace `sdlc` with any plugin name from the [Available Plugins](#available-plug | **observability** | -- | -- | -- | All 14 lifecycle events โ†’ structured JSONL via agentic_events | | **delegation** | -- | `delegating-to-claude-p`, `delegating-to-codex`, `writing-handoffs` | -- | -- | | **experiments** | -- | `running-experiments` | -- | -- | +| **plugin-doctor** | -- | -- | -- | SessionStart plugin-freshness check (weekly, never auto-updates) | --- diff --git a/docs/superpowers/plans/2026-07-16-plugin-doctor-implementation.md b/docs/superpowers/plans/2026-07-16-plugin-doctor-implementation.md new file mode 100644 index 00000000..fe0b01fb --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-plugin-doctor-implementation.md @@ -0,0 +1,932 @@ +# plugin-doctor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build `plugin-doctor@agentic-primitives`, a plugin that warns (via a `SessionStart` hook) when installed `agentic-primitives` plugins have newer versions available, checked at most weekly, and that never updates anything on its own. + +**Architecture:** Pure comparison logic (semver parsing, installed-vs-catalog diffing, cadence gating, state I/O) lives in `plugins/plugin-doctor/hooks/lib/freshness.py`, unit-tested directly with no subprocess or real filesystem paths. A thin `session-start.py` handler wires that logic to real paths (with env-var overrides for testability), optionally refreshes the marketplace cache via `claude plugin marketplace update`, and emits `hookSpecificOutput.additionalContext` when something's outdated. Everything fails silent/open, matching every other hook handler in this repo. + +**Tech Stack:** Python 3.11+ (stdlib only โ€” `json`, `subprocess`, `pathlib`, `datetime`, `importlib.util`), pytest, following the existing `plugins/sdlc/hooks/validators/` pure-function + dynamic-import pattern. + +## Global Constraints + +- Only compare plugins sourced from the `agentic-primitives` marketplace โ€” never other marketplaces (spec ยง"Non-goals"). +- Refresh the marketplace catalog at most once every 7 days; comparison itself runs every session against whatever's cached (spec ยง3). +- Never run `claude plugin update` automatically โ€” only inform Claude via `additionalContext`, which must ask the user first (spec ยง3 step 4, ยง"Goal"). +- All failure modes (missing cache, malformed JSON, `claude` not on `PATH`, no network) fail silent/open โ€” never block session start (spec ยง4). +- No manual "check now" slash command in this plan โ€” out of scope per spec (spec ยง"Non-goals"). +- New plugin, not an addition to an existing one: `plugins/plugin-doctor/`, `.claude-plugin/plugin.json` with `name`, `version`, `description`, `author`, `repository` (per `CLAUDE.md` "Plugin Structure" โ€” only `name` is strictly required, but existing plugins set all five). +- Must be registered in `.claude-plugin/marketplace.json` and both README.md tables per `CLAUDE.md` "Add a plugin" checklist. +- Run `just qa-fix` before considering the work done (repo convention). + +--- + +### Task 1: State I/O and cadence gating + +**Files:** +- Create: `plugins/plugin-doctor/hooks/lib/freshness.py` +- Test: `tests/unit/claude/hooks/test_plugin_doctor.py` + +**Interfaces:** +- Produces: `freshness.read_state(state_path: Path) -> dict`, `freshness.write_state(state_path: Path, state: dict) -> None`, `freshness.is_check_due(last_checked_at: str | None, now: datetime, interval_days: int = 7) -> bool`. Task 2 imports and extends this same module; Task 3's handler calls all three functions. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/unit/claude/hooks/test_plugin_doctor.py`: + +```python +#!/usr/bin/env python3 +""" +Tests for the plugin-doctor plugin. + +Covers: +- freshness.py pure functions: state I/O, cadence gating, semver + comparison, and context-message formatting. +- session-start.py handler: end-to-end via subprocess with env-var + path overrides, matching the run_handler() pattern in test_hooks.py. +""" + +import importlib.util +import json +import os +import subprocess +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.parent +PLUGIN_DOCTOR = PROJECT_ROOT / "plugins" / "plugin-doctor" +PLUGIN_DOCTOR_LIB = PLUGIN_DOCTOR / "hooks" / "lib" +PLUGIN_DOCTOR_HANDLERS = PLUGIN_DOCTOR / "hooks" / "handlers" + + +def load_freshness(): + """Load freshness.py directly, bypassing package/import-path setup.""" + module_path = PLUGIN_DOCTOR_LIB / "freshness.py" + spec = importlib.util.spec_from_file_location("plugin_doctor_freshness", module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def run_session_start(env_overrides: dict) -> dict: + """Run session-start.py as a subprocess with path overrides. + + PLUGIN_DOCTOR_SKIP_REFRESH defaults to "1" so tests never shell out + to the real `claude` CLI or touch the network. + """ + handler_path = PLUGIN_DOCTOR_HANDLERS / "session-start.py" + env = dict(os.environ) + env.update(env_overrides) + env.setdefault("PLUGIN_DOCTOR_SKIP_REFRESH", "1") + + result = subprocess.run( + [sys.executable, str(handler_path)], + input=json.dumps({"session_id": "test"}).encode(), + capture_output=True, + env=env, + timeout=5, + ) + + stdout = result.stdout.decode().strip() + if not stdout: + return {} + return json.loads(stdout) + + +# ============================================================================ +# Task 1: State I/O and cadence gating +# ============================================================================ + + +class TestStateIO: + def test_read_state_missing_file_returns_empty_dict(self, tmp_path): + freshness = load_freshness() + result = freshness.read_state(tmp_path / "does-not-exist" / "state.json") + assert result == {} + + def test_read_state_malformed_json_returns_empty_dict(self, tmp_path): + freshness = load_freshness() + state_path = tmp_path / "state.json" + state_path.write_text("{not valid json") + assert freshness.read_state(state_path) == {} + + def test_write_state_then_read_state_roundtrips(self, tmp_path): + freshness = load_freshness() + state_path = tmp_path / "nested" / "state.json" + freshness.write_state(state_path, {"last_checked_at": "2026-07-16T00:00:00+00:00"}) + assert freshness.read_state(state_path) == { + "last_checked_at": "2026-07-16T00:00:00+00:00" + } + + +class TestIsCheckDue: + def test_missing_last_checked_at_is_due(self): + freshness = load_freshness() + now = datetime(2026, 7, 16, tzinfo=timezone.utc) + assert freshness.is_check_due(None, now) is True + + def test_malformed_last_checked_at_is_due(self): + freshness = load_freshness() + now = datetime(2026, 7, 16, tzinfo=timezone.utc) + assert freshness.is_check_due("not-a-date", now) is True + + def test_recent_check_is_not_due(self): + freshness = load_freshness() + now = datetime(2026, 7, 16, tzinfo=timezone.utc) + last = (now - timedelta(days=3)).isoformat() + assert freshness.is_check_due(last, now) is False + + def test_check_exactly_at_interval_is_due(self): + freshness = load_freshness() + now = datetime(2026, 7, 16, tzinfo=timezone.utc) + last = (now - timedelta(days=7)).isoformat() + assert freshness.is_check_due(last, now) is True + + def test_check_older_than_interval_is_due(self): + freshness = load_freshness() + now = datetime(2026, 7, 16, tzinfo=timezone.utc) + last = (now - timedelta(days=10)).isoformat() + assert freshness.is_check_due(last, now) is True +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /Users/neural/Code/AgentParadise/agentic-primitives && uv run pytest tests/unit/claude/hooks/test_plugin_doctor.py -v` +Expected: FAIL โ€” `plugins/plugin-doctor/hooks/lib/freshness.py` doesn't exist, so `load_freshness()` raises (`spec` is `None`, `AttributeError` on `.loader`). + +- [ ] **Step 3: Write the minimal implementation** + +Create `plugins/plugin-doctor/hooks/lib/freshness.py`: + +```python +""" +Pure functions for plugin-doctor: state I/O, cadence gating, semver +comparison, and outdated-plugin context formatting. + +No I/O side effects except read_state/write_state, which take explicit +paths so callers (and tests) control where state lives. No subprocess +calls anywhere in this module โ€” the marketplace refresh subprocess call +lives in session-start.py, not here. +""" + +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +def read_state(state_path: Path) -> dict: + """Read the state file, returning {} if missing or malformed.""" + try: + return json.loads(state_path.read_text()) + except (OSError, json.JSONDecodeError): + return {} + + +def write_state(state_path: Path, state: dict) -> None: + """Write the state file, creating parent directories as needed.""" + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text(json.dumps(state)) + + +def is_check_due( + last_checked_at: str | None, now: datetime, interval_days: int = 7 +) -> bool: + """True if a refresh is due: no valid last_checked_at, or it's at + least interval_days old.""" + if not last_checked_at: + return True + try: + last = datetime.fromisoformat(last_checked_at) + except ValueError: + return True + if last.tzinfo is None: + last = last.replace(tzinfo=timezone.utc) + return now - last >= timedelta(days=interval_days) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /Users/neural/Code/AgentParadise/agentic-primitives && uv run pytest tests/unit/claude/hooks/test_plugin_doctor.py -v` +Expected: PASS โ€” 8 tests (3 `TestStateIO` + 5 `TestIsCheckDue`). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/neural/Code/AgentParadise/agentic-primitives +git add plugins/plugin-doctor/hooks/lib/freshness.py tests/unit/claude/hooks/test_plugin_doctor.py +git commit -m "feat(plugin-doctor): add state I/O and cadence gating" +``` + +--- + +### Task 2: Semver comparison and context formatting + +**Files:** +- Modify: `plugins/plugin-doctor/hooks/lib/freshness.py` +- Modify: `tests/unit/claude/hooks/test_plugin_doctor.py` + +**Interfaces:** +- Consumes: nothing from Task 1's functions directly (independent pure functions in the same module). +- Produces: `freshness.parse_semver(version: str) -> tuple[int, int, int] | None`, `freshness.get_installed_versions(cache_root: Path) -> dict[str, str]`, `freshness.get_catalog_versions(marketplace_plugins_root: Path) -> dict[str, str]`, `freshness.diff_outdated(installed: dict[str, str], catalog: dict[str, str]) -> dict[str, tuple[str, str]]`, `freshness.format_context(outdated: dict[str, tuple[str, str]]) -> str`. Task 3's handler calls all five. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/unit/claude/hooks/test_plugin_doctor.py` (after the `TestIsCheckDue` class): + +```python +# ============================================================================ +# Task 2: Semver comparison and context formatting +# ============================================================================ + + +class TestParseSemver: + def test_parses_standard_version(self): + freshness = load_freshness() + assert freshness.parse_semver("1.4.0") == (1, 4, 0) + + def test_parses_version_with_prerelease_suffix(self): + freshness = load_freshness() + assert freshness.parse_semver("1.4.0-beta") == (1, 4, 0) + + def test_returns_none_for_non_semver_string(self): + freshness = load_freshness() + assert freshness.parse_semver("unknown") is None + + def test_returns_none_for_non_string_input(self): + freshness = load_freshness() + assert freshness.parse_semver(None) is None + + +class TestGetInstalledVersions: + def test_missing_cache_root_returns_empty_dict(self, tmp_path): + freshness = load_freshness() + assert freshness.get_installed_versions(tmp_path / "nope") == {} + + def test_reads_single_version_per_plugin(self, tmp_path): + freshness = load_freshness() + (tmp_path / "sdlc" / "1.4.0").mkdir(parents=True) + (tmp_path / "meta" / "1.3.0").mkdir(parents=True) + assert freshness.get_installed_versions(tmp_path) == { + "sdlc": "1.4.0", + "meta": "1.3.0", + } + + def test_picks_highest_version_when_multiple_present(self, tmp_path): + freshness = load_freshness() + (tmp_path / "sdlc" / "1.3.0").mkdir(parents=True) + (tmp_path / "sdlc" / "1.4.0").mkdir(parents=True) + assert freshness.get_installed_versions(tmp_path) == {"sdlc": "1.4.0"} + + def test_skips_non_semver_version_directories(self, tmp_path): + freshness = load_freshness() + (tmp_path / "sdlc" / "unknown").mkdir(parents=True) + assert freshness.get_installed_versions(tmp_path) == {} + + +class TestGetCatalogVersions: + def test_missing_marketplace_root_returns_empty_dict(self, tmp_path): + freshness = load_freshness() + assert freshness.get_catalog_versions(tmp_path / "nope") == {} + + def test_reads_version_from_plugin_json(self, tmp_path): + freshness = load_freshness() + manifest_dir = tmp_path / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text( + json.dumps({"name": "sdlc", "version": "1.5.0"}) + ) + assert freshness.get_catalog_versions(tmp_path) == {"sdlc": "1.5.0"} + + def test_skips_plugin_missing_manifest(self, tmp_path): + freshness = load_freshness() + (tmp_path / "sdlc").mkdir(parents=True) + assert freshness.get_catalog_versions(tmp_path) == {} + + def test_skips_malformed_manifest(self, tmp_path): + freshness = load_freshness() + manifest_dir = tmp_path / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text("{not valid json") + assert freshness.get_catalog_versions(tmp_path) == {} + + +class TestDiffOutdated: + def test_flags_plugin_behind_catalog(self): + freshness = load_freshness() + outdated = freshness.diff_outdated({"sdlc": "1.4.0"}, {"sdlc": "1.5.0"}) + assert outdated == {"sdlc": ("1.4.0", "1.5.0")} + + def test_up_to_date_plugin_not_flagged(self): + freshness = load_freshness() + assert freshness.diff_outdated({"sdlc": "1.4.0"}, {"sdlc": "1.4.0"}) == {} + + def test_plugin_missing_from_catalog_not_flagged(self): + freshness = load_freshness() + assert freshness.diff_outdated({"local-only": "0.1.0"}, {}) == {} + + def test_unparsable_versions_not_flagged(self): + freshness = load_freshness() + assert freshness.diff_outdated({"sdlc": "unknown"}, {"sdlc": "1.5.0"}) == {} + + +class TestFormatContext: + def test_lists_each_outdated_plugin_with_versions(self): + freshness = load_freshness() + message = freshness.format_context({"sdlc": ("1.4.0", "1.5.0")}) + assert "sdlc: 1.4.0 -> 1.5.0" in message + + def test_instructs_claude_to_ask_before_updating(self): + freshness = load_freshness() + message = freshness.format_context({"sdlc": ("1.4.0", "1.5.0")}) + assert "ask" in message.lower() + assert ( + "never run a plugin update without the user explicitly agreeing" + in message.lower() + ) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /Users/neural/Code/AgentParadise/agentic-primitives && uv run pytest tests/unit/claude/hooks/test_plugin_doctor.py -v` +Expected: FAIL โ€” `AttributeError: module 'plugin_doctor_freshness' has no attribute 'parse_semver'` (and similarly for the other four new functions). + +- [ ] **Step 3: Write the minimal implementation** + +Append to `plugins/plugin-doctor/hooks/lib/freshness.py` (add `import re` to the existing imports at the top, then append these functions): + +```python +import re + +SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)") + + +def parse_semver(version) -> tuple[int, int, int] | None: + """Parse a semver-ish string like '1.4.0' into (1, 4, 0). + + Returns None if the string doesn't start with N.N.N (e.g. "unknown"), + or if version isn't a string at all. + """ + if not isinstance(version, str): + return None + match = SEMVER_RE.match(version.strip()) + if not match: + return None + return (int(match.group(1)), int(match.group(2)), int(match.group(3))) + + +def get_installed_versions(cache_root: Path) -> dict[str, str]: + """Return {plugin_name: version} for each plugin under cache_root. + + cache_root is e.g. ~/.claude/plugins/cache/agentic-primitives, where + each child directory is a plugin name containing one or more + version-numbered subdirectories. If more than one version directory + exists for a plugin, the highest by semver wins. + """ + installed: dict[str, str] = {} + if not cache_root.is_dir(): + return installed + + for plugin_dir in sorted(cache_root.iterdir()): + if not plugin_dir.is_dir(): + continue + candidates = [ + (name, parsed) + for name in (d.name for d in plugin_dir.iterdir() if d.is_dir()) + if (parsed := parse_semver(name)) is not None + ] + if not candidates: + continue + best_version, _ = max(candidates, key=lambda item: item[1]) + installed[plugin_dir.name] = best_version + + return installed + + +def get_catalog_versions(marketplace_plugins_root: Path) -> dict[str, str]: + """Return {plugin_name: version} read from each plugin's plugin.json. + + marketplace_plugins_root is e.g. + ~/.claude/plugins/marketplaces/agentic-primitives/plugins, where each + child directory is a plugin name containing .claude-plugin/plugin.json. + """ + catalog: dict[str, str] = {} + if not marketplace_plugins_root.is_dir(): + return catalog + + for plugin_dir in sorted(marketplace_plugins_root.iterdir()): + manifest_path = plugin_dir / ".claude-plugin" / "plugin.json" + if not manifest_path.is_file(): + continue + try: + data = json.loads(manifest_path.read_text()) + except (OSError, json.JSONDecodeError): + continue + version = data.get("version") + if isinstance(version, str) and parse_semver(version) is not None: + catalog[plugin_dir.name] = version + + return catalog + + +def diff_outdated( + installed: dict[str, str], catalog: dict[str, str] +) -> dict[str, tuple[str, str]]: + """Return {plugin_name: (installed_version, catalog_version)} for each + plugin where the catalog version is strictly newer than installed. + + Plugins missing from the catalog, or with unparsable versions on + either side, are skipped rather than flagged. + """ + outdated: dict[str, tuple[str, str]] = {} + for name, installed_version in installed.items(): + catalog_version = catalog.get(name) + if catalog_version is None: + continue + installed_parsed = parse_semver(installed_version) + catalog_parsed = parse_semver(catalog_version) + if installed_parsed is None or catalog_parsed is None: + continue + if catalog_parsed > installed_parsed: + outdated[name] = (installed_version, catalog_version) + return outdated + + +def format_context(outdated: dict[str, tuple[str, str]]) -> str: + """Build the additionalContext message describing outdated plugins.""" + lines = [ + "agentic-primitives plugin update check: " + f"{len(outdated)} installed plugin(s) have newer versions available.", + ] + for name, (installed_version, catalog_version) in sorted(outdated.items()): + lines.append(f" - {name}: {installed_version} -> {catalog_version}") + lines.append( + "Mention this to the user near the start of the conversation and ask " + "if they'd like to update. If they agree, run " + "`claude plugin update @agentic-primitives` for each plugin " + "they approve. Never run a plugin update without the user explicitly " + "agreeing first." + ) + return "\n".join(lines) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /Users/neural/Code/AgentParadise/agentic-primitives && uv run pytest tests/unit/claude/hooks/test_plugin_doctor.py -v` +Expected: PASS โ€” 21 tests total (8 from Task 1 + 13 new). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/neural/Code/AgentParadise/agentic-primitives +git add plugins/plugin-doctor/hooks/lib/freshness.py tests/unit/claude/hooks/test_plugin_doctor.py +git commit -m "feat(plugin-doctor): add semver comparison and context formatting" +``` + +--- + +### Task 3: Plugin scaffold and session-start handler + +**Files:** +- Create: `plugins/plugin-doctor/.claude-plugin/plugin.json` +- Create: `plugins/plugin-doctor/hooks/hooks.json` +- Create: `plugins/plugin-doctor/hooks/handlers/session-start.py` +- Modify: `tests/unit/claude/hooks/test_plugin_doctor.py` + +**Interfaces:** +- Consumes: `freshness.read_state`, `freshness.write_state`, `freshness.is_check_due`, `freshness.get_installed_versions`, `freshness.get_catalog_versions`, `freshness.diff_outdated`, `freshness.format_context` (all from Tasks 1โ€“2). +- Produces: a runnable `session-start.py` that reads a `PreToolUse`-style JSON event from stdin and either prints nothing or `{"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "..."}}`. Honors `PLUGIN_DOCTOR_CACHE_DIR`, `PLUGIN_DOCTOR_MARKETPLACE_DIR`, `PLUGIN_DOCTOR_STATE_PATH`, `PLUGIN_DOCTOR_SKIP_REFRESH` env var overrides โ€” Task 4 doesn't touch these, but any future test or manual run relies on this exact contract. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/unit/claude/hooks/test_plugin_doctor.py`: + +```python +# ============================================================================ +# Task 3: session-start.py handler (end-to-end via subprocess) +# ============================================================================ + + +class TestSessionStartHandler: + def test_no_context_when_all_plugins_up_to_date(self, tmp_path): + cache_root = tmp_path / "cache" + (cache_root / "sdlc" / "1.4.0").mkdir(parents=True) + marketplace_root = tmp_path / "marketplace" + manifest_dir = marketplace_root / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text(json.dumps({"version": "1.4.0"})) + state_path = tmp_path / "state.json" + state_path.write_text( + json.dumps({"last_checked_at": datetime.now(timezone.utc).isoformat()}) + ) + + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + } + ) + assert result == {} + + def test_emits_context_when_plugin_outdated(self, tmp_path): + cache_root = tmp_path / "cache" + (cache_root / "sdlc" / "1.4.0").mkdir(parents=True) + marketplace_root = tmp_path / "marketplace" + manifest_dir = marketplace_root / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text(json.dumps({"version": "1.5.0"})) + state_path = tmp_path / "state.json" + state_path.write_text( + json.dumps({"last_checked_at": datetime.now(timezone.utc).isoformat()}) + ) + + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + } + ) + assert result["hookSpecificOutput"]["hookEventName"] == "SessionStart" + assert "sdlc: 1.4.0 -> 1.5.0" in result["hookSpecificOutput"]["additionalContext"] + + def test_writes_state_when_check_is_due(self, tmp_path): + cache_root = tmp_path / "cache" + cache_root.mkdir() + marketplace_root = tmp_path / "marketplace" + marketplace_root.mkdir() + state_path = tmp_path / "state.json" # does not exist yet -> due + + run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + } + ) + assert state_path.exists() + assert "last_checked_at" in json.loads(state_path.read_text()) + + def test_does_not_touch_state_when_check_not_due(self, tmp_path): + cache_root = tmp_path / "cache" + cache_root.mkdir() + marketplace_root = tmp_path / "marketplace" + marketplace_root.mkdir() + state_path = tmp_path / "state.json" + original = {"last_checked_at": datetime.now(timezone.utc).isoformat()} + state_path.write_text(json.dumps(original)) + + run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + } + ) + assert json.loads(state_path.read_text()) == original + + def test_missing_cache_and_marketplace_dirs_no_crash(self, tmp_path): + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(tmp_path / "no-cache"), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(tmp_path / "no-marketplace"), + "PLUGIN_DOCTOR_STATE_PATH": str(tmp_path / "state.json"), + } + ) + assert result == {} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /Users/neural/Code/AgentParadise/agentic-primitives && uv run pytest tests/unit/claude/hooks/test_plugin_doctor.py -v` +Expected: FAIL โ€” `plugins/plugin-doctor/hooks/handlers/session-start.py` doesn't exist, so `subprocess.run` fails to find the interpreter target / `result.stdout` is empty and `FileNotFoundError` or non-zero exit surfaces as a test failure (`json.loads` on empty output raising, or subprocess raising `FileNotFoundError` for the missing script path). + +- [ ] **Step 3: Write the minimal implementation** + +Create `plugins/plugin-doctor/.claude-plugin/plugin.json`: + +```json +{ + "name": "plugin-doctor", + "version": "0.1.0", + "description": "Warns when installed agentic-primitives plugins have updates available, checked weekly. Never auto-updates.", + "author": { + "name": "NeuralEmpowerment" + }, + "repository": "https://github.com/AgentParadise/agentic-primitives" +} +``` + +Create `plugins/plugin-doctor/hooks/hooks.json`: + +```json +{ + "description": "Warns when installed agentic-primitives plugins have newer versions available (checked at most weekly). Never auto-updates.", + "hooks": { + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/handlers/session-start.py", + "timeout": 15 + } + ] + } + ] + } +} +``` + +Create `plugins/plugin-doctor/hooks/handlers/session-start.py`: + +```python +#!/usr/bin/env python3 +""" +SessionStart Handler - Warns when installed agentic-primitives plugins +are outdated. + +Checks at most once every CHECK_INTERVAL_DAYS (state persisted in +~/.claude/plugin-doctor/state.json, or PLUGIN_DOCTOR_STATE_PATH override). +Never updates plugins itself -- only informs Claude via additionalContext, +which is instructed to ask the user before running any update. +""" + +import importlib.util +import json +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path + +CHECK_INTERVAL_DAYS = 7 +MARKETPLACE_NAME = "agentic-primitives" + + +def _load_freshness(): + module_path = Path(__file__).parent.parent / "lib" / "freshness.py" + spec = importlib.util.spec_from_file_location( + "plugin_doctor_freshness", module_path + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +freshness = _load_freshness() + + +def _cache_root() -> Path: + override = os.environ.get("PLUGIN_DOCTOR_CACHE_DIR") + if override: + return Path(override) + return Path.home() / ".claude" / "plugins" / "cache" / MARKETPLACE_NAME + + +def _marketplace_plugins_root() -> Path: + override = os.environ.get("PLUGIN_DOCTOR_MARKETPLACE_DIR") + if override: + return Path(override) + return ( + Path.home() + / ".claude" + / "plugins" + / "marketplaces" + / MARKETPLACE_NAME + / "plugins" + ) + + +def _state_path() -> Path: + override = os.environ.get("PLUGIN_DOCTOR_STATE_PATH") + if override: + return Path(override) + return Path.home() / ".claude" / "plugin-doctor" / "state.json" + + +def _refresh_marketplace() -> None: + if os.environ.get("PLUGIN_DOCTOR_SKIP_REFRESH") == "1": + return + try: + subprocess.run( + ["claude", "plugin", "marketplace", "update", MARKETPLACE_NAME], + capture_output=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired): + pass + + +def main() -> None: + try: + if not sys.stdin.isatty(): + sys.stdin.read() # drain stdin so Claude Code doesn't see a hang + + state_path = _state_path() + state = freshness.read_state(state_path) + now = datetime.now(timezone.utc) + + if freshness.is_check_due( + state.get("last_checked_at"), now, CHECK_INTERVAL_DAYS + ): + _refresh_marketplace() + freshness.write_state(state_path, {"last_checked_at": now.isoformat()}) + + installed = freshness.get_installed_versions(_cache_root()) + catalog = freshness.get_catalog_versions(_marketplace_plugins_root()) + outdated = freshness.diff_outdated(installed, catalog) + + if not outdated: + return + + print( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": freshness.format_context(outdated), + } + } + ) + ) + + except Exception: + pass # Fail open -- never block session start + + +if __name__ == "__main__": + main() +``` + +Make it executable: + +```bash +chmod +x plugins/plugin-doctor/hooks/handlers/session-start.py +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /Users/neural/Code/AgentParadise/agentic-primitives && uv run pytest tests/unit/claude/hooks/test_plugin_doctor.py -v` +Expected: PASS โ€” 26 tests total (21 from Tasks 1โ€“2 + 5 new). + +Then validate the manifest and hooks directly: + +Run: `cd /Users/neural/Code/AgentParadise/agentic-primitives && claude plugin validate plugins/plugin-doctor` +Expected: no errors reported for `plugins/plugin-doctor` (the command may print unrelated pre-existing warnings for other plugins if run against the whole repo โ€” running it scoped to `plugins/plugin-doctor` avoids that noise). + +- [ ] **Step 5: Commit** + +```bash +cd /Users/neural/Code/AgentParadise/agentic-primitives +git add plugins/plugin-doctor tests/unit/claude/hooks/test_plugin_doctor.py +git commit -m "feat(plugin-doctor): add SessionStart handler and plugin manifest" +``` + +--- + +### Task 4: Marketplace registration and docs + +**Files:** +- Modify: `.claude-plugin/marketplace.json` +- Modify: `README.md` +- Modify: `CHANGELOG.md` +- Create: `plugins/plugin-doctor/README.md` +- Create: `plugins/plugin-doctor/CHANGELOG.md` + +**Interfaces:** +- Consumes: nothing new โ€” this task only adds registration/documentation for the plugin built in Tasks 1โ€“3. +- Produces: nothing consumed by other tasks โ€” this is the last task in the plan. + +- [ ] **Step 1: Register in the marketplace catalog** + +In `.claude-plugin/marketplace.json`, the `"plugins"` array currently ends with the `"experiments"` entry followed by ` }\n ]\n}`. Add a new entry after `"experiments"` and before the closing `]`: + +```json + { + "name": "experiments", + "description": "Hypothesis-first experiment workflow โ€” scaffolding, eval packs, results, verdicts, and the two-commit discipline", + "source": "./plugins/experiments", + "category": "development" + }, + { + "name": "plugin-doctor", + "description": "Warns when installed agentic-primitives plugins have updates available, checked weekly. Never auto-updates.", + "source": "./plugins/plugin-doctor", + "category": "observability" + } + ] +} +``` + +(i.e. add a trailing comma to the existing `"experiments"` entry's closing `}` and insert the new object before the final `]`.) + +- [ ] **Step 2: Validate the marketplace manifest** + +Run: `cd /Users/neural/Code/AgentParadise/agentic-primitives && claude plugin validate .claude-plugin/marketplace.json` +Expected: no errors mentioning `plugin-doctor` or malformed JSON. + +- [ ] **Step 3: Add the plugin README** + +Create `plugins/plugin-doctor/README.md`: + +```markdown +# ๐Ÿฉบ plugin-doctor + +Warns you when installed `agentic-primitives` plugins have newer versions available. Checked at most once a week. Never updates anything on its own โ€” it only tells Claude, who asks you. + +## How it works + +On `SessionStart`, plugin-doctor: + +1. Checks `~/.claude/plugin-doctor/state.json` for when it last refreshed the marketplace catalog. +2. If that was 7+ days ago (or never), runs `claude plugin marketplace update agentic-primitives` to refresh the local catalog cache, then records the new check time โ€” regardless of whether the refresh succeeded, so a network hiccup costs one missed week, not a retry loop. +3. Compares every installed `agentic-primitives` plugin's version (read from `~/.claude/plugins/cache/agentic-primitives///`) against the catalog version (read from `~/.claude/plugins/marketplaces/agentic-primitives/plugins//.claude-plugin/plugin.json`). +4. If anything's outdated, tells Claude via `additionalContext` โ€” Claude will mention it early in the conversation and ask if you want to update. It will never run `claude plugin update` without you explicitly agreeing. + +Only `agentic-primitives`-sourced plugins are checked โ€” not other marketplaces. + +## Install + +```bash +claude plugin install plugin-doctor@agentic-primitives --scope user +``` + +## Updating manually + +If you don't want to wait for the weekly check: + +```bash +claude plugin marketplace update agentic-primitives +claude plugin update @agentic-primitives +``` +``` + +- [ ] **Step 4: Add the plugin CHANGELOG** + +Create `plugins/plugin-doctor/CHANGELOG.md`: + +```markdown +# Changelog + +## 0.1.0 โ€” 2026-07-16 + +- Initial release: `SessionStart` hook warns when installed `agentic-primitives` plugins have newer versions available, checked at most weekly, never auto-updates. +``` + +- [ ] **Step 5: Update the root README** + +In `README.md`, the "Available Plugins" table currently ends with the `experiments` row. Add a new row immediately after it: + +```markdown +| **experiments** | `claude plugin install experiments@agentic-primitives --scope user` | Hypothesis-first experiment workflow | +| **plugin-doctor** | `claude plugin install plugin-doctor@agentic-primitives --scope user` | Warns when installed plugins are outdated | +``` + +In the "What's in each plugin" table, add a row immediately after the `experiments` row: + +```markdown +| **experiments** | -- | `running-experiments` | -- | -- | +| **plugin-doctor** | -- | -- | -- | SessionStart plugin-freshness check (weekly, never auto-updates) | +``` + +- [ ] **Step 6: Update the root CHANGELOG** + +In `CHANGELOG.md`, under the existing `## [Unreleased]` heading (before the first `###` subsection), add a new subsection: + +```markdown +### ๐Ÿฉบ plugin-doctor + +A new plugin that warns when installed `agentic-primitives` plugins have updates available. + +#### Added + +- **`plugins/plugin-doctor/`** โ€” `SessionStart` hook compares installed plugin versions (from `~/.claude/plugins/cache/agentic-primitives/`) against the marketplace catalog (from `~/.claude/plugins/marketplaces/agentic-primitives/`), refreshing the catalog at most once a week. Emits `additionalContext` naming outdated plugins when found; instructs Claude to ask the user before updating anything. Never runs `claude plugin update` itself. +- **26 unit/integration tests** in `tests/unit/claude/hooks/test_plugin_doctor.py` covering state I/O, cadence gating, semver comparison, and the handler end-to-end via subprocess. +``` + +- [ ] **Step 7: Run full QA and commit** + +Run: `cd /Users/neural/Code/AgentParadise/agentic-primitives && just qa-fix` +Expected: formatting applied, lint passes, all tests pass (including the 26 new ones). + +```bash +cd /Users/neural/Code/AgentParadise/agentic-primitives +git add .claude-plugin/marketplace.json README.md CHANGELOG.md plugins/plugin-doctor/README.md plugins/plugin-doctor/CHANGELOG.md +git commit -m "docs(plugin-doctor): register plugin and document" +``` + +--- + +## Manual verification (post-merge, not automated) + +`plugin-doctor` can only be installed once this branch is merged and the `agentic-primitives` GitHub marketplace source picks it up (the configured marketplace source is the GitHub repo, not a local path โ€” see `claude plugin marketplace list`). After merging: + +```bash +claude plugin marketplace update agentic-primitives +claude plugin install plugin-doctor@agentic-primitives --scope user +``` + +Then start a new Claude Code session and confirm no crash. To force an "outdated" scenario for a real end-to-end smoke test, temporarily edit the cached `plugin.json` version for an already-installed plugin down a version, start a session, and confirm the warning appears and that Claude asks before updating rather than updating automatically. diff --git a/docs/superpowers/plans/2026-07-17-plugin-doctor-release-age-gate.md b/docs/superpowers/plans/2026-07-17-plugin-doctor-release-age-gate.md new file mode 100644 index 00000000..f191178b --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-plugin-doctor-release-age-gate.md @@ -0,0 +1,636 @@ +# plugin-doctor Release-Age Gate Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a 48-hour-minimum-release-age gate to `plugin-doctor`, verified directly against GitHub's per-plugin release tags, so a version is never surfaced to the user the moment it lands โ€” closing the supply-chain gap in the original design (requested change on PR #267). + +**Architecture:** A new pure function `is_release_old_enough` in `freshness.py` does the age comparison. A new network function `_fetch_release_commit_date` in `session-start.py` resolves a plugin's release tag to its commit date via a single unauthenticated GitHub REST API call (stdlib `urllib.request` only), following the same fail-open pattern as the existing `_refresh_marketplace`. Both are wired into `main()`: the age-check only runs inside the existing weekly `is_check_due` block, only for plugins already found outdated that session, and its results are cached in `state.json` under a new `release_ages` key so non-due sessions never hit the network. + +**Tech Stack:** Python 3.11+ stdlib only (`urllib.request`, `http.server` for test fixtures). No new dependencies. + +## Global Constraints + +- Minimum release age: 48 hours, exact GitHub endpoint: `GET {api_base}/repos/AgentParadise/agentic-primitives/commits?sha=/v&per_page=1`, commit date at `response[0].commit.committer.date`. +- The age-check network call fires **only** inside the existing weekly `is_check_due` gate (`CHECK_INTERVAL_DAYS = 7`), and only for plugins `diff_outdated` already found outdated that session. It must never fire on a non-due session. +- Any failure resolving the age (network error, timeout, non-2xx response, malformed JSON, unexpected shape, or a `None`/unparseable commit date) is treated identically to "not old enough" โ€” fail-safe suppression, never fail-open-to-showing. This is the opposite fail direction from the rest of the plugin's error handling (which fails open to *not blocking*, not to *showing unverified info*) โ€” be precise about this distinction. +- No new external dependency: the fetch uses stdlib `urllib.request` only, ~10s timeout, matching the existing `subprocess.run(..., timeout=10)` pattern already used for `_refresh_marketplace`. +- The GitHub API base URL is overridable via `PLUGIN_DOCTOR_GITHUB_API_BASE` (default `https://api.github.com`), for tests only โ€” production code never needs to set it. +- `release_ages` in `state.json` is recomputed from scratch on every due session (not merged with the prior contents) โ€” a plugin no longer outdated must drop out of the cache, not linger. +- A cached `release_ages` entry only counts as "verified" if its `version` field matches the *current* catalog version for that plugin; a mismatch (catalog bumped again since the last weekly check) is treated as unverified. +- `write_state` must still be called unconditionally whenever `is_check_due` is true, regardless of whether the marketplace refresh or any age-check succeeded โ€” this existing constraint from the original design is unchanged. +- Tests follow this repo's "real behavior, not mocks" convention: the GitHub fetch is tested against a real local `http.server.HTTPServer` on an ephemeral port, not `unittest.mock`. + +--- + +### Task 1: `is_release_old_enough` pure function + +**Files:** +- Modify: `plugins/plugin-doctor/hooks/lib/freshness.py` +- Modify: `tests/unit/claude/hooks/test_plugin_doctor.py` + +**Interfaces:** +- Produces: `freshness.is_release_old_enough(commit_date_iso: str | None, now: datetime, min_age_hours: int = 48) -> bool`. Task 2's handler calls this exact signature. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/unit/claude/hooks/test_plugin_doctor.py`, after the existing `TestFormatContext` class (before `class TestSessionStartHandler:`): + +```python +# ============================================================================ +# Release-age gate: is_release_old_enough (pure function) +# ============================================================================ + + +class TestIsReleaseOldEnough: + def test_none_input_is_not_old_enough(self): + freshness = load_freshness() + now = datetime(2026, 7, 17, tzinfo=timezone.utc) + assert freshness.is_release_old_enough(None, now) is False + + def test_unparseable_date_is_not_old_enough(self): + freshness = load_freshness() + now = datetime(2026, 7, 17, tzinfo=timezone.utc) + assert freshness.is_release_old_enough("not-a-date", now) is False + + def test_recent_commit_is_not_old_enough(self): + freshness = load_freshness() + now = datetime(2026, 7, 17, tzinfo=timezone.utc) + commit_date = (now - timedelta(hours=10)).isoformat().replace("+00:00", "Z") + assert freshness.is_release_old_enough(commit_date, now) is False + + def test_commit_exactly_48_hours_old_is_old_enough(self): + freshness = load_freshness() + now = datetime(2026, 7, 17, tzinfo=timezone.utc) + commit_date = (now - timedelta(hours=48)).isoformat().replace("+00:00", "Z") + assert freshness.is_release_old_enough(commit_date, now) is True + + def test_commit_older_than_48_hours_is_old_enough(self): + freshness = load_freshness() + now = datetime(2026, 7, 17, tzinfo=timezone.utc) + commit_date = (now - timedelta(hours=72)).isoformat().replace("+00:00", "Z") + assert freshness.is_release_old_enough(commit_date, now) is True + + def test_custom_min_age_hours(self): + freshness = load_freshness() + now = datetime(2026, 7, 17, tzinfo=timezone.utc) + commit_date = (now - timedelta(hours=5)).isoformat().replace("+00:00", "Z") + assert freshness.is_release_old_enough(commit_date, now, min_age_hours=1) is True +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /Users/neural/Code/AgentParadise/agentic-primitives/.worktrees/plugin-doctor && uv run pytest tests/unit/claude/hooks/test_plugin_doctor.py -v -k TestIsReleaseOldEnough` +Expected: FAIL โ€” `AttributeError: module 'plugin_doctor_freshness' has no attribute 'is_release_old_enough'`. + +- [ ] **Step 3: Write the minimal implementation** + +Append to `plugins/plugin-doctor/hooks/lib/freshness.py` (at the end of the file, after `format_context`): + +```python +def is_release_old_enough( + commit_date_iso: str | None, now: datetime, min_age_hours: int = 48 +) -> bool: + """True if commit_date_iso represents a timestamp at least + min_age_hours before now. + + Returns False (fail-safe) if commit_date_iso is None or unparseable. + This backs a supply-chain-safety gate: an unknown release age must + never be treated as "old enough to recommend." + """ + if not commit_date_iso: + return False + try: + commit_date = datetime.fromisoformat(commit_date_iso.replace("Z", "+00:00")) + except (ValueError, TypeError): + return False + if commit_date.tzinfo is None: + commit_date = commit_date.replace(tzinfo=timezone.utc) + return now - commit_date >= timedelta(hours=min_age_hours) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /Users/neural/Code/AgentParadise/agentic-primitives/.worktrees/plugin-doctor && uv run pytest tests/unit/claude/hooks/test_plugin_doctor.py -v -k TestIsReleaseOldEnough` +Expected: PASS โ€” 6 tests. + +Then run the full file to confirm nothing else broke: + +Run: `uv run pytest tests/unit/claude/hooks/test_plugin_doctor.py -v` +Expected: PASS โ€” all previously-passing tests still pass, plus these 6 new ones. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/neural/Code/AgentParadise/agentic-primitives/.worktrees/plugin-doctor +git add plugins/plugin-doctor/hooks/lib/freshness.py tests/unit/claude/hooks/test_plugin_doctor.py +git commit -m "feat(plugin-doctor): add is_release_old_enough pure function" +``` + +--- + +### Task 2: GitHub release-age fetch, `main()` wiring, and end-to-end tests + +**Files:** +- Modify: `plugins/plugin-doctor/hooks/handlers/session-start.py` +- Modify: `tests/unit/claude/hooks/test_plugin_doctor.py` + +**Interfaces:** +- Consumes: `freshness.is_release_old_enough(commit_date_iso, now, min_age_hours)` from Task 1; `freshness.read_state`, `freshness.write_state`, `freshness.is_check_due`, `freshness.get_installed_versions`, `freshness.get_catalog_versions`, `freshness.diff_outdated`, `freshness.format_context` (all pre-existing, unchanged signatures). +- Produces: the final `main()` behavior described in this task โ€” no other task consumes this directly, it's the last code task in this plan. + +**Important โ€” one pre-existing test will break and must be replaced, not just left failing:** `test_emits_context_when_plugin_outdated` (in `TestSessionStartHandler`) currently sets a *recent* `last_checked_at` (not due) and asserts immediate emission with no age verification at all โ€” that assumption is exactly what this task removes. Delete that test and replace it with the new due/verified-emission test below (`test_outdated_plugin_old_enough_release_is_surfaced`), which covers the same "emits when outdated" behavior correctly under the new gate. Every other existing test in `TestSessionStartHandler` uses either an up-to-date cache or an empty cache (no outdated plugins), so the age-gate logic doesn't affect them โ€” leave those as-is. + +- [ ] **Step 1: Write the failing tests** + +First, add the HTTP fixture server helper near the top of `tests/unit/claude/hooks/test_plugin_doctor.py`, right after the existing `run_session_start` function definition: + +```python +import threading +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qs, urlparse + + +class _FixtureGitHubHandler(BaseHTTPRequestHandler): + """Serves canned GitHub commits-API responses for a single test. + + Subclassed per-test via start_fixture_server() with a `responses` + dict mapping the "sha" query param value (e.g. "sdlc/v1.5.0") to + (status_code, json_body). A sha with no matching entry gets a 404, + matching GitHub's real behavior for a nonexistent ref. + """ + + responses: dict = {} + + def do_GET(self): + query = parse_qs(urlparse(self.path).query) + sha = query.get("sha", [None])[0] + if sha in self.responses: + status, body = self.responses[sha] + else: + status, body = 404, {"message": "Not Found"} + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(body).encode()) + + def log_message(self, format, *args): + pass # silence default request logging to stderr + + +def start_fixture_server(responses: dict) -> tuple[str, HTTPServer]: + """Start a background HTTP server for one test. + + Returns (base_url, server). Caller must call server.shutdown() (and + server.server_close(), which shutdown() triggers via the thread + target) when done -- a `try/finally` around the test body is the + simplest way to guarantee that. + """ + handler_class = type("_Handler", (_FixtureGitHubHandler,), {"responses": responses}) + server = HTTPServer(("127.0.0.1", 0), handler_class) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return f"http://127.0.0.1:{server.server_port}", server +``` + +Then, in `TestSessionStartHandler` (the existing class โ€” do not create a new class), first **delete** `test_emits_context_when_plugin_outdated` entirely, then **add** these tests (place them after the existing `test_no_context_when_all_plugins_up_to_date`): + +```python + def test_outdated_plugin_old_enough_release_is_surfaced(self, tmp_path): + cache_root = tmp_path / "cache" + (cache_root / "sdlc" / "1.4.0").mkdir(parents=True) + marketplace_root = tmp_path / "marketplace" + manifest_dir = marketplace_root / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text(json.dumps({"version": "1.5.0"})) + state_path = tmp_path / "state.json" # missing -> due + + old_commit_date = ( + (datetime.now(timezone.utc) - timedelta(hours=72)) + .isoformat() + .replace("+00:00", "Z") + ) + base_url, server = start_fixture_server( + {"sdlc/v1.5.0": (200, [{"commit": {"committer": {"date": old_commit_date}}}])} + ) + try: + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + "PLUGIN_DOCTOR_GITHUB_API_BASE": base_url, + } + ) + finally: + server.shutdown() + + assert result["hookSpecificOutput"]["hookEventName"] == "SessionStart" + assert ( + "sdlc: 1.4.0 -> 1.5.0" in result["hookSpecificOutput"]["additionalContext"] + ) + written = json.loads(state_path.read_text()) + assert written["release_ages"]["sdlc"] == {"version": "1.5.0", "old_enough": True} + + def test_outdated_plugin_too_recent_release_is_suppressed(self, tmp_path): + cache_root = tmp_path / "cache" + (cache_root / "sdlc" / "1.4.0").mkdir(parents=True) + marketplace_root = tmp_path / "marketplace" + manifest_dir = marketplace_root / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text(json.dumps({"version": "1.5.0"})) + state_path = tmp_path / "state.json" + + recent_commit_date = ( + (datetime.now(timezone.utc) - timedelta(hours=5)) + .isoformat() + .replace("+00:00", "Z") + ) + base_url, server = start_fixture_server( + { + "sdlc/v1.5.0": ( + 200, + [{"commit": {"committer": {"date": recent_commit_date}}}], + ) + } + ) + try: + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + "PLUGIN_DOCTOR_GITHUB_API_BASE": base_url, + } + ) + finally: + server.shutdown() + + assert result == {} + written = json.loads(state_path.read_text()) + assert written["release_ages"]["sdlc"] == {"version": "1.5.0", "old_enough": False} + + def test_outdated_plugin_untagged_version_is_suppressed(self, tmp_path): + cache_root = tmp_path / "cache" + (cache_root / "sdlc" / "1.4.0").mkdir(parents=True) + marketplace_root = tmp_path / "marketplace" + manifest_dir = marketplace_root / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text(json.dumps({"version": "1.5.0"})) + state_path = tmp_path / "state.json" + + base_url, server = start_fixture_server({}) # no matching sha -> 404 + try: + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + "PLUGIN_DOCTOR_GITHUB_API_BASE": base_url, + } + ) + finally: + server.shutdown() + + assert result == {} + written = json.loads(state_path.read_text()) + assert written["release_ages"]["sdlc"]["old_enough"] is False + + def test_outdated_plugin_unreachable_github_is_suppressed(self, tmp_path): + cache_root = tmp_path / "cache" + (cache_root / "sdlc" / "1.4.0").mkdir(parents=True) + marketplace_root = tmp_path / "marketplace" + manifest_dir = marketplace_root / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text(json.dumps({"version": "1.5.0"})) + state_path = tmp_path / "state.json" + + closed_server = HTTPServer(("127.0.0.1", 0), _FixtureGitHubHandler) + unreachable_url = f"http://127.0.0.1:{closed_server.server_port}" + closed_server.server_close() # bound and closed -- nothing is listening + + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + "PLUGIN_DOCTOR_GITHUB_API_BASE": unreachable_url, + } + ) + assert result == {} + + def test_stale_release_ages_entry_for_old_version_is_not_reused(self, tmp_path): + cache_root = tmp_path / "cache" + (cache_root / "sdlc" / "1.4.0").mkdir(parents=True) + marketplace_root = tmp_path / "marketplace" + manifest_dir = marketplace_root / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text(json.dumps({"version": "1.6.0"})) + state_path = tmp_path / "state.json" + # Recent last_checked_at -> NOT due, so no fresh age-check runs this + # session. The cached entry is for 1.5.0, but the catalog now shows + # 1.6.0 -- a version the cache never verified. + state_path.write_text( + json.dumps( + { + "last_checked_at": datetime.now(timezone.utc).isoformat(), + "release_ages": {"sdlc": {"version": "1.5.0", "old_enough": True}}, + } + ) + ) + + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + } + ) + assert result == {} + + def test_non_due_session_reuses_cached_release_ages_without_network_call( + self, tmp_path + ): + cache_root = tmp_path / "cache" + (cache_root / "sdlc" / "1.4.0").mkdir(parents=True) + marketplace_root = tmp_path / "marketplace" + manifest_dir = marketplace_root / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text(json.dumps({"version": "1.5.0"})) + state_path = tmp_path / "state.json" + state_path.write_text( + json.dumps( + { + "last_checked_at": datetime.now(timezone.utc).isoformat(), # NOT due + "release_ages": {"sdlc": {"version": "1.5.0", "old_enough": True}}, + } + ) + ) + + # Point at an unreachable GitHub API base. If the handler tried to + # hit the network here, the fetch would fail and (per the fail-safe + # rule) old_enough would come back False -- suppressing the result. + # Asserting the cached True value is still honored proves the + # handler never attempted a network call on this non-due session. + closed_server = HTTPServer(("127.0.0.1", 0), _FixtureGitHubHandler) + unreachable_url = f"http://127.0.0.1:{closed_server.server_port}" + closed_server.server_close() + + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + "PLUGIN_DOCTOR_GITHUB_API_BASE": unreachable_url, + } + ) + assert result["hookSpecificOutput"]["hookEventName"] == "SessionStart" + assert ( + "sdlc: 1.4.0 -> 1.5.0" in result["hookSpecificOutput"]["additionalContext"] + ) + + def test_due_session_recomputes_release_ages_dropping_stale_entries( + self, tmp_path + ): + # sdlc is now up to date, but state still has a stale release_ages + # entry for it from a previous (now-resolved) outdated check. + cache_root = tmp_path / "cache" + (cache_root / "sdlc" / "1.5.0").mkdir(parents=True) + marketplace_root = tmp_path / "marketplace" + manifest_dir = marketplace_root / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text(json.dumps({"version": "1.5.0"})) + state_path = tmp_path / "state.json" + old_timestamp = ( + datetime.now(timezone.utc) - timedelta(days=10) + ).isoformat() # due + state_path.write_text( + json.dumps( + { + "last_checked_at": old_timestamp, + "release_ages": {"sdlc": {"version": "1.4.0", "old_enough": True}}, + } + ) + ) + + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + } + ) + assert result == {} + written = json.loads(state_path.read_text()) + assert written["release_ages"] == {} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /Users/neural/Code/AgentParadise/agentic-primitives/.worktrees/plugin-doctor && uv run pytest tests/unit/claude/hooks/test_plugin_doctor.py -v -k TestSessionStartHandler` +Expected: FAIL โ€” the new tests fail because `session-start.py` doesn't yet read `PLUGIN_DOCTOR_GITHUB_API_BASE`, doesn't yet call any GitHub fetch, and doesn't yet write a `release_ages` key to state, so the assertions on `written["release_ages"]` raise `KeyError`, and the "surfaced" tests get `result == {}` instead of the expected `additionalContext` (since nothing currently gates or checks age, but nothing currently *adds* a `release_ages` cache either, so state won't contain the key the tests check). + +- [ ] **Step 3: Write the minimal implementation** + +Replace the full contents of `plugins/plugin-doctor/hooks/handlers/session-start.py` with: + +```python +#!/usr/bin/env python3 +""" +SessionStart Handler - Warns when installed agentic-primitives plugins +are outdated AND their newer version has been released on GitHub for +at least MIN_RELEASE_AGE_HOURS. + +Checks at most once every CHECK_INTERVAL_DAYS (state persisted in +~/.claude/plugin-doctor/state.json, or PLUGIN_DOCTOR_STATE_PATH override). +Never updates plugins itself -- only informs Claude via additionalContext, +which is instructed to ask the user before running any update. +""" + +import importlib.util +import json +import os +import subprocess +import sys +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +CHECK_INTERVAL_DAYS = 7 +MARKETPLACE_NAME = "agentic-primitives" +GITHUB_OWNER = "AgentParadise" +GITHUB_REPO = "agentic-primitives" +MIN_RELEASE_AGE_HOURS = 48 + + +def _load_freshness(): + module_path = Path(__file__).parent.parent / "lib" / "freshness.py" + spec = importlib.util.spec_from_file_location( + "plugin_doctor_freshness", module_path + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +freshness = _load_freshness() + + +def _cache_root() -> Path: + override = os.environ.get("PLUGIN_DOCTOR_CACHE_DIR") + if override: + return Path(override) + return Path.home() / ".claude" / "plugins" / "cache" / MARKETPLACE_NAME + + +def _marketplace_plugins_root() -> Path: + override = os.environ.get("PLUGIN_DOCTOR_MARKETPLACE_DIR") + if override: + return Path(override) + return ( + Path.home() + / ".claude" + / "plugins" + / "marketplaces" + / MARKETPLACE_NAME + / "plugins" + ) + + +def _state_path() -> Path: + override = os.environ.get("PLUGIN_DOCTOR_STATE_PATH") + if override: + return Path(override) + return Path.home() / ".claude" / "plugin-doctor" / "state.json" + + +def _github_api_base() -> str: + return os.environ.get("PLUGIN_DOCTOR_GITHUB_API_BASE", "https://api.github.com") + + +def _refresh_marketplace() -> None: + if os.environ.get("PLUGIN_DOCTOR_SKIP_REFRESH") == "1": + return + try: + subprocess.run( + ["claude", "plugin", "marketplace", "update", MARKETPLACE_NAME], + capture_output=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired): + pass + + +def _fetch_release_commit_date(plugin: str, version: str) -> str | None: + """Resolve the /v release tag to its commit date via + the GitHub commits API. Returns None on any failure -- network error, + non-2xx response (including 404 for an untagged version), or an + unexpected response shape.""" + url = ( + f"{_github_api_base()}/repos/{GITHUB_OWNER}/{GITHUB_REPO}/commits" + f"?sha={plugin}/v{version}&per_page=1" + ) + try: + req = urllib.request.Request( + url, headers={"Accept": "application/vnd.github+json"} + ) + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read().decode()) + return data[0]["commit"]["committer"]["date"] + except Exception: + return None + + +def main() -> None: + try: + if not sys.stdin.isatty(): + sys.stdin.read() # drain stdin so Claude Code doesn't see a hang + + state_path = _state_path() + state = freshness.read_state(state_path) + now = datetime.now(timezone.utc) + due = freshness.is_check_due( + state.get("last_checked_at"), now, CHECK_INTERVAL_DAYS + ) + + if due: + _refresh_marketplace() + + installed = freshness.get_installed_versions(_cache_root()) + catalog = freshness.get_catalog_versions(_marketplace_plugins_root()) + outdated = freshness.diff_outdated(installed, catalog) + + release_ages = state.get("release_ages", {}) + if not isinstance(release_ages, dict): + release_ages = {} + + if due: + release_ages = {} + for name, (_installed_version, catalog_version) in outdated.items(): + commit_date = _fetch_release_commit_date(name, catalog_version) + old_enough = freshness.is_release_old_enough( + commit_date, now, MIN_RELEASE_AGE_HOURS + ) + release_ages[name] = { + "version": catalog_version, + "old_enough": old_enough, + } + freshness.write_state( + state_path, + {"last_checked_at": now.isoformat(), "release_ages": release_ages}, + ) + + verified_outdated = { + name: versions + for name, versions in outdated.items() + if isinstance(release_ages.get(name), dict) + and release_ages[name].get("version") == versions[1] + and release_ages[name].get("old_enough") is True + } + + if not verified_outdated: + return + + print( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": freshness.format_context( + verified_outdated + ), + } + } + ) + ) + + except Exception: + pass # Fail open -- never block session start + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /Users/neural/Code/AgentParadise/agentic-primitives/.worktrees/plugin-doctor && uv run pytest tests/unit/claude/hooks/test_plugin_doctor.py -v` +Expected: PASS โ€” every test in the file, including all of Task 1's and Task 2's new tests, and every pre-existing test except the deleted `test_emits_context_when_plugin_outdated`. + +Then confirm the plugin manifest and hooks are still valid (nothing in this task touches `plugin.json` or `hooks.json`, but confirm no regression): + +Run: `claude plugin validate plugins/plugin-doctor` +Expected: no errors reported for `plugins/plugin-doctor`. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/neural/Code/AgentParadise/agentic-primitives/.worktrees/plugin-doctor +git add plugins/plugin-doctor/hooks/handlers/session-start.py tests/unit/claude/hooks/test_plugin_doctor.py +git commit -m "feat(plugin-doctor): gate outdated-version warnings on a 48h GitHub release age" +``` + +--- + +## Manual verification (post-merge, not automated) + +Same as the original plan's manual-verification note: once this branch is merged and installed via `claude plugin update plugin-doctor@agentic-primitives`, the real end-to-end proof is starting a session shortly after a genuine `agentic-primitives` plugin release lands (tagged `/v` on GitHub) and confirming plugin-doctor stays silent about it for the first 48 hours, then surfaces it on the first weekly check after that window closes. diff --git a/docs/superpowers/specs/2026-07-16-plugin-doctor-design.md b/docs/superpowers/specs/2026-07-16-plugin-doctor-design.md new file mode 100644 index 00000000..5d10a911 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-plugin-doctor-design.md @@ -0,0 +1,143 @@ +# plugin-doctor: agentic-primitives plugin freshness monitor + +## Context + +`agentic-primitives` plugins are versioned and updatable (`claude plugin update `), but nothing tells a user an update exists โ€” they only find out by manually running `claude plugin marketplace update` + `claude plugin update ` per plugin, or by noticing something's broken. This surfaced concretely in `agent-paradise-standards-system`, where a vendored (pre-plugin-system) copy of `sdlc`'s hook handlers silently drifted for months, shipping an invalid `PreToolUse`/`PostToolUse` JSON schema (`{"decision": "allow"}`, not a valid value) until it started failing hook validation. + +That specific case was a vendored copy, not a real plugin install, so it's out of scope for this fix directly โ€” but it exposed the general gap: **installed plugins have no freshness signal at all.** This spec covers that general gap for anyone using the real plugin system. + +## Goal + +A new plugin, `plugin-doctor@agentic-primitives`, that: +- Checks, at most once a week, whether any installed `agentic-primitives` plugin has a newer version available. +- Verifies that newer version has actually been live on GitHub for at least 48 hours before ever mentioning it โ€” a supply-chain safety cooldown, so a freshly-pushed (and potentially bad or quickly-reverted) release is never surfaced the moment it lands. +- Surfaces outdated-and-old-enough plugins to the user near the start of a session via Claude, who asks whether to update. +- Never runs an update automatically. Update only happens if the user explicitly agrees, at which point Claude runs `claude plugin update @agentic-primitives` itself (a normal, permission-gated Bash call). + +## Non-goals + +- Checking plugins from other marketplaces (`claude-plugins-official`, `syntropic137`, etc.) โ€” noisy, and update cadence for those isn't ours to reason about. +- Fixing vendored/non-plugin-system copies (like the `agent-paradise-standards-system` case) โ€” those need a one-time manual re-sync or migration to the real plugin system; this plugin can't see them. +- Auto-installing updates. +- A manual "check now" slash command โ€” YAGNI for v1; can be added later if the weekly cadence proves too coarse. + +## Design + +### 1. Plugin shape + +New plugin at `plugins/plugin-doctor/`, following the existing layout: + +``` +plugins/plugin-doctor/ + .claude-plugin/ + plugin.json + hooks/ + hooks.json + handlers/ + session-start.py +``` + +Single-purpose: one `SessionStart` hook, one handler script. No commands, skills, or agents. + +`plugin.json`: +```json +{ + "name": "plugin-doctor", + "version": "0.1.0", + "description": "Warns when installed agentic-primitives plugins have updates available", + "author": { "name": "NeuralEmpowerment" }, + "repository": "https://github.com/AgentParadise/agentic-primitives" +} +``` + +### 2. Version comparison โ€” pure filesystem reads + +No subprocess needed for the comparison itself: + +- **Installed version**: for each directory under `~/.claude/plugins/cache/agentic-primitives//`, the directory name is the installed version (e.g. `~/.claude/plugins/cache/agentic-primitives/sdlc/1.4.0/`). This naturally enumerates every `agentic-primitives` plugin currently installed at user scope, enabled or not. +- **Catalog (latest known) version**: read `"version"` from `~/.claude/plugins/marketplaces/agentic-primitives/plugins//.claude-plugin/plugin.json`. +- Compare with a semver-aware comparison (not string comparison โ€” `1.10.0 > 1.9.0`). + +If the marketplace cache doesn't have an entry for an installed plugin (renamed/removed upstream), skip it silently โ€” not this plugin's job to flag that. + +### 3. State & refresh cadence + +State file at `~/.claude/plugin-doctor/state.json` (self-managed; not one of Claude's internal plugin-data conventions): + +```json +{ "last_checked_at": "2026-07-16T12:00:00Z" } +``` + +On `SessionStart`: + +1. Read `last_checked_at`. Missing or unparseable โ†’ treat as due. +2. If `now - last_checked_at >= 7 days`: + - Run `claude plugin marketplace update agentic-primitives` as a subprocess, timeout ~10s. + - Write `last_checked_at = now` **regardless of whether the refresh succeeded.** This keeps the cadence predictable: a transient network failure costs one missed week, not a retry-every-session loop. +3. Compare installed vs. catalog version for every plugin found under `~/.claude/plugins/cache/agentic-primitives/*` (per section 2). +4. Filter step 3's outdated set down to only plugins whose newer version has been verified as release-age-eligible (per section 6) โ€” see section 6 for exactly when that verification runs. +5. If any remain, emit `hookSpecificOutput.additionalContext` naming each outdated plugin and its versions (e.g. `sdlc 1.4.0 โ†’ 1.5.0`), with an instruction to Claude: + - Mention this near the start of the conversation. + - Ask the user whether they want to update. + - Never run `claude plugin update` without the user explicitly agreeing. +6. If nothing remains after the age filter, emit nothing โ€” no-op, matching the convention of every other hook in this repo (empty output = allow/no context). This is independent of whether the weekly refresh ran this session: the comparison in step 3 always runs against whatever's cached, so a session can still report outdated-and-old-enough plugins (or report nothing) regardless of whether step 2's refresh fired. + +### 4. Error handling + +Fail silent/open in every case, matching the rest of the hook handlers in this repo: + +- `claude` not on `PATH` โ†’ skip the marketplace refresh, still do the local comparison against whatever's cached. +- Marketplace cache directory missing entirely โ†’ nothing to compare against, no-op. +- Malformed `plugin.json` or state file โ†’ treat as missing, don't crash. +- No network during refresh โ†’ subprocess fails, `last_checked_at` still advances per section 3. + +None of these should ever block a session or print an error the user has to deal with. + +### 6. GitHub release-age gate (supply-chain safety) + +`agentic-primitives` tags each plugin release on GitHub as `/v` (via the repo's own `claude plugin tag` command) โ€” a deliberate, intentional release marker, distinct from a version bump merely landing on `main`. This gives a real "when was this actually released" timestamp that the local marketplace cache (a shallow, non-git snapshot) cannot provide. + +**Mechanism**: a single unauthenticated GitHub REST API call resolves a tag straight to its commit date: + +``` +GET https://api.github.com/repos/AgentParadise/agentic-primitives/commits?sha=/v&per_page=1 +``` + +The response's `[0].commit.committer.date` is the release timestamp. Unauthenticated rate limit is 60 requests/hour โ€” ample given this call only fires weekly and only for plugins already found outdated (typically 0-2 per week). + +**When it runs**: only inside the existing step 2 weekly-due block, and only for plugins step 3 already found outdated that session (using the just-refreshed catalog). It does **not** run every session โ€” that would mean a network call on every session start for as long as a candidate stays under 48 hours old, which is both wasteful and unnecessary given the weekly cadence already in place. + +**Caching**: the result is cached in `state.json` under a new `release_ages` key, keyed by plugin name, alongside the version it was checked against: + +```json +{ + "last_checked_at": "2026-07-16T12:00:00Z", + "release_ages": { + "sdlc": { "version": "1.5.0", "old_enough": false } + } +} +``` + +Each weekly due-check recomputes `release_ages` from scratch (discarding stale entries for plugins that are no longer outdated, so it never grows unbounded). Step 4's filter (above) checks this cache: a plugin is only surfaced if `release_ages[plugin].version` matches the *current* catalog version and `old_enough` is `true`. A plugin that just became outdated has no matching cache entry yet โ€” the filter treats "no entry" the same as "not old enough," so it's suppressed until the *next* weekly check reaches it. This is intentional: it means detection-to-notification latency is up to one extra week in the worst case, which is an acceptable cost for the safety property this section exists to provide. + +**Failure handling**: any failure resolving the age (network down, rate-limited, malformed response, or โ€” critically โ€” the tag not existing yet because a version landed on `main` but hasn't been tagged/released) is treated identically to "not old enough" (`old_enough: false`). This is a deliberate fail-safe: plugin-doctor must never recommend an update it couldn't verify is actually a confirmed, aged release. No new external dependency is added for this โ€” the fetch uses stdlib `urllib.request` only, with a ~10s timeout, matching the "stdlib only" constraint from section 5. + +**Configurability**: the minimum age (`48` hours) and the GitHub API base URL are both overridable โ€” the latter purely for testing (see section 7), pointing it at a local fixture server instead of the real GitHub API. + +### 7. Testing + +The version-diffing and cadence-gating logic are pure functions, independent of the subprocess call and of real `~/.claude` paths โ€” same separation pattern as the `sdlc` plugin's `validate(...)` validators (`plugins/sdlc/hooks/validators/`). Tests inject a fake cache directory and a fake state file path rather than touching real user state or shelling out. + +Cases to cover: +- No installed plugins โ†’ no-op. +- All installed plugins at latest version โ†’ no context emitted. +- One or more outdated โ†’ context lists exactly the outdated ones with correct version strings. +- State file missing/malformed โ†’ treated as due. +- Refresh due but `claude` missing from `PATH` โ†’ falls back to comparing against existing cache, doesn't crash. +- Semver comparison correctness (e.g. `1.10.0` vs `1.9.0`). + +**Release-age gate testing** (section 6): the same "real behavior, not mocks" principle applies to the GitHub fetch โ€” rather than mocking `urllib.request`, tests spin up a real local `http.server.HTTPServer` on an ephemeral port in a background thread, serving canned JSON fixture responses, and point the handler at it via the GitHub-API-base-URL override. This mirrors how the marketplace-refresh subprocess path is tested with a real fake `claude` executable on `PATH` rather than a mocked `subprocess.run`. + +Additional cases to cover: +- `is_release_old_enough` (pure): `None` input โ†’ `False`; unparseable date string โ†’ `False`; a date less than 48h before `now` โ†’ `False`; a date 48h+ before `now` โ†’ `True`; a date exactly 48h before `now` โ†’ `True` (boundary, `>=`). +- Handler end-to-end: an outdated plugin whose fixture server returns a commit date 48h+ old โ†’ surfaced in `additionalContext`. An outdated plugin whose fixture server returns a commit date under 48h old โ†’ NOT surfaced, but still recorded in `release_ages` with `old_enough: false`. A fixture server returning 404 (tag not found) โ†’ NOT surfaced, fails open, no crash. Fixture server unreachable (nothing listening on the target port) โ†’ NOT surfaced, fails open, no crash. A cached `release_ages` entry from a prior version that no longer matches the current catalog version โ†’ treated as unverified, NOT surfaced. Weekly-due session correctly recomputes `release_ages` from scratch (a plugin no longer outdated drops out of the cache). Non-due session correctly reuses the cached `release_ages` without hitting the fixture server at all. diff --git a/plugins/plugin-doctor/.claude-plugin/plugin.json b/plugins/plugin-doctor/.claude-plugin/plugin.json new file mode 100644 index 00000000..b7befd86 --- /dev/null +++ b/plugins/plugin-doctor/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "plugin-doctor", + "version": "0.1.0", + "description": "Warns when installed agentic-primitives plugins have updates available, checked weekly. Never auto-updates.", + "author": { + "name": "NeuralEmpowerment" + }, + "repository": "https://github.com/AgentParadise/agentic-primitives" +} diff --git a/plugins/plugin-doctor/CHANGELOG.md b/plugins/plugin-doctor/CHANGELOG.md new file mode 100644 index 00000000..70021850 --- /dev/null +++ b/plugins/plugin-doctor/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.1.0 โ€” 2026-07-16 + +- Initial release: `SessionStart` hook warns when installed `agentic-primitives` plugins have newer versions available, checked at most weekly, never auto-updates. diff --git a/plugins/plugin-doctor/README.md b/plugins/plugin-doctor/README.md new file mode 100644 index 00000000..c5b5feb0 --- /dev/null +++ b/plugins/plugin-doctor/README.md @@ -0,0 +1,29 @@ +# ๐Ÿฉบ plugin-doctor + +Warns you when installed `agentic-primitives` plugins have newer versions available. Checked at most once a week. Never updates anything on its own โ€” it only tells Claude, who asks you. + +## How it works + +On `SessionStart`, plugin-doctor: + +1. Checks `~/.claude/plugin-doctor/state.json` for when it last refreshed the marketplace catalog. +2. If that was 7+ days ago (or never), runs `claude plugin marketplace update agentic-primitives` to refresh the local catalog cache, then records the new check time โ€” regardless of whether the refresh succeeded, so a network hiccup costs one missed week, not a retry loop. +3. Compares every installed `agentic-primitives` plugin's version (read from `~/.claude/plugins/cache/agentic-primitives///`) against the catalog version (read from `~/.claude/plugins/marketplaces/agentic-primitives/plugins//.claude-plugin/plugin.json`). +4. If anything's outdated, tells Claude via `additionalContext` โ€” Claude will mention it early in the conversation and ask if you want to update. It will never run `claude plugin update` without you explicitly agreeing. + +Only `agentic-primitives`-sourced plugins are checked โ€” not other marketplaces. + +## Install + +```bash +claude plugin install plugin-doctor@agentic-primitives --scope user +``` + +## Updating manually + +If you don't want to wait for the weekly check: + +```bash +claude plugin marketplace update agentic-primitives +claude plugin update @agentic-primitives +``` diff --git a/plugins/plugin-doctor/hooks/handlers/session-start.py b/plugins/plugin-doctor/hooks/handlers/session-start.py new file mode 100755 index 00000000..083f1855 --- /dev/null +++ b/plugins/plugin-doctor/hooks/handlers/session-start.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +SessionStart Handler - Warns when installed agentic-primitives plugins +are outdated AND their newer version has been released on GitHub for +at least MIN_RELEASE_AGE_HOURS. + +Checks at most once every CHECK_INTERVAL_DAYS (state persisted in +~/.claude/plugin-doctor/state.json, or PLUGIN_DOCTOR_STATE_PATH override). +Never updates plugins itself -- only informs Claude via additionalContext, +which is instructed to ask the user before running any update. +""" + +import importlib.util +import json +import os +import subprocess +import sys +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +CHECK_INTERVAL_DAYS = 7 +MARKETPLACE_NAME = "agentic-primitives" +GITHUB_OWNER = "AgentParadise" +GITHUB_REPO = "agentic-primitives" +MIN_RELEASE_AGE_HOURS = 48 + + +def _load_freshness(): + module_path = Path(__file__).parent.parent / "lib" / "freshness.py" + spec = importlib.util.spec_from_file_location( + "plugin_doctor_freshness", module_path + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +freshness = _load_freshness() + + +def _cache_root() -> Path: + override = os.environ.get("PLUGIN_DOCTOR_CACHE_DIR") + if override: + return Path(override) + return Path.home() / ".claude" / "plugins" / "cache" / MARKETPLACE_NAME + + +def _marketplace_plugins_root() -> Path: + override = os.environ.get("PLUGIN_DOCTOR_MARKETPLACE_DIR") + if override: + return Path(override) + return ( + Path.home() + / ".claude" + / "plugins" + / "marketplaces" + / MARKETPLACE_NAME + / "plugins" + ) + + +def _state_path() -> Path: + override = os.environ.get("PLUGIN_DOCTOR_STATE_PATH") + if override: + return Path(override) + return Path.home() / ".claude" / "plugin-doctor" / "state.json" + + +def _github_api_base() -> str: + return os.environ.get("PLUGIN_DOCTOR_GITHUB_API_BASE", "https://api.github.com") + + +def _refresh_marketplace() -> None: + if os.environ.get("PLUGIN_DOCTOR_SKIP_REFRESH") == "1": + return + try: + subprocess.run( + ["claude", "plugin", "marketplace", "update", MARKETPLACE_NAME], + capture_output=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired): + pass + + +def _fetch_release_commit_date(plugin: str, version: str) -> str | None: + """Resolve the /v release tag to its commit date via + the GitHub commits API. Returns None on any failure -- network error, + non-2xx response (including 404 for an untagged version), or an + unexpected response shape.""" + sha = urllib.parse.quote(f"{plugin}/v{version}", safe="") + url = ( + f"{_github_api_base()}/repos/{GITHUB_OWNER}/{GITHUB_REPO}/commits" + f"?sha={sha}&per_page=1" + ) + try: + req = urllib.request.Request( + url, headers={"Accept": "application/vnd.github+json"} + ) + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read().decode()) + return data[0]["commit"]["committer"]["date"] + except Exception: + return None + + +def main() -> None: + try: + if not sys.stdin.isatty(): + sys.stdin.read() # drain stdin so Claude Code doesn't see a hang + + state_path = _state_path() + state = freshness.read_state(state_path) + now = datetime.now(timezone.utc) + due = freshness.is_check_due( + state.get("last_checked_at"), now, CHECK_INTERVAL_DAYS + ) + + if due: + _refresh_marketplace() + + installed = freshness.get_installed_versions(_cache_root()) + catalog = freshness.get_catalog_versions(_marketplace_plugins_root()) + outdated = freshness.diff_outdated(installed, catalog) + + release_ages = state.get("release_ages", {}) + if not isinstance(release_ages, dict): + release_ages = {} + + if due: + release_ages = {} + for name, (_installed_version, catalog_version) in outdated.items(): + commit_date = _fetch_release_commit_date(name, catalog_version) + old_enough = freshness.is_release_old_enough( + commit_date, now, MIN_RELEASE_AGE_HOURS + ) + release_ages[name] = { + "version": catalog_version, + "old_enough": old_enough, + } + freshness.write_state( + state_path, + {"last_checked_at": now.isoformat(), "release_ages": release_ages}, + ) + + verified_outdated = { + name: versions + for name, versions in outdated.items() + if isinstance(release_ages.get(name), dict) + and release_ages[name].get("version") == versions[1] + and release_ages[name].get("old_enough") is True + } + + if not verified_outdated: + return + + print( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": freshness.format_context( + verified_outdated + ), + } + } + ) + ) + + except Exception: + pass # Fail open -- never block session start + + +if __name__ == "__main__": + main() diff --git a/plugins/plugin-doctor/hooks/hooks.json b/plugins/plugin-doctor/hooks/hooks.json new file mode 100644 index 00000000..22686f9f --- /dev/null +++ b/plugins/plugin-doctor/hooks/hooks.json @@ -0,0 +1,17 @@ +{ + "description": "Warns when installed agentic-primitives plugins have newer versions available (checked at most weekly). Never auto-updates.", + "hooks": { + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/handlers/session-start.py", + "timeout": 15 + } + ] + } + ] + } +} diff --git a/plugins/plugin-doctor/hooks/lib/freshness.py b/plugins/plugin-doctor/hooks/lib/freshness.py new file mode 100644 index 00000000..9d8fb759 --- /dev/null +++ b/plugins/plugin-doctor/hooks/lib/freshness.py @@ -0,0 +1,182 @@ +""" +Pure functions for plugin-doctor: state I/O, cadence gating, semver +comparison, and outdated-plugin context formatting. + +No I/O side effects except read_state/write_state, which take explicit +paths so callers (and tests) control where state lives. No subprocess +calls anywhere in this module โ€” the marketplace refresh subprocess call +lives in session-start.py, not here. +""" + +import json +import re +from datetime import datetime, timedelta, timezone +from pathlib import Path + + +def read_state(state_path: Path) -> dict: + """Read the state file, returning {} if missing or malformed.""" + try: + data = json.loads(state_path.read_text()) + if not isinstance(data, dict): + return {} + return data + except (OSError, json.JSONDecodeError): + return {} + + +def write_state(state_path: Path, state: dict) -> None: + """Write the state file, creating parent directories as needed.""" + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text(json.dumps(state)) + + +def is_check_due( + last_checked_at: str | None, now: datetime, interval_days: int = 7 +) -> bool: + """True if a refresh is due: no valid last_checked_at, or it's at + least interval_days old.""" + if not last_checked_at: + return True + try: + last = datetime.fromisoformat(last_checked_at) + except (ValueError, TypeError): + return True + if last.tzinfo is None: + last = last.replace(tzinfo=timezone.utc) + return now - last >= timedelta(days=interval_days) + + +SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)") + + +def parse_semver(version) -> tuple[int, int, int] | None: + """Parse a semver-ish string like '1.4.0' into (1, 4, 0). + + Returns None if the string doesn't start with N.N.N (e.g. "unknown"), + or if version isn't a string at all. + """ + if not isinstance(version, str): + return None + match = SEMVER_RE.match(version.strip()) + if not match: + return None + return (int(match.group(1)), int(match.group(2)), int(match.group(3))) + + +def get_installed_versions(cache_root: Path) -> dict[str, str]: + """Return {plugin_name: version} for each plugin under cache_root. + + cache_root is e.g. ~/.claude/plugins/cache/agentic-primitives, where + each child directory is a plugin name containing one or more + version-numbered subdirectories. If more than one version directory + exists for a plugin, the highest by semver wins. + """ + installed: dict[str, str] = {} + if not cache_root.is_dir(): + return installed + + for plugin_dir in sorted(cache_root.iterdir()): + if not plugin_dir.is_dir(): + continue + candidates = [ + (name, parsed) + for name in (d.name for d in plugin_dir.iterdir() if d.is_dir()) + if (parsed := parse_semver(name)) is not None + ] + if not candidates: + continue + best_version, _ = max(candidates, key=lambda item: item[1]) + installed[plugin_dir.name] = best_version + + return installed + + +def get_catalog_versions(marketplace_plugins_root: Path) -> dict[str, str]: + """Return {plugin_name: version} read from each plugin's plugin.json. + + marketplace_plugins_root is e.g. + ~/.claude/plugins/marketplaces/agentic-primitives/plugins, where each + child directory is a plugin name containing .claude-plugin/plugin.json. + """ + catalog: dict[str, str] = {} + if not marketplace_plugins_root.is_dir(): + return catalog + + for plugin_dir in sorted(marketplace_plugins_root.iterdir()): + manifest_path = plugin_dir / ".claude-plugin" / "plugin.json" + if not manifest_path.is_file(): + continue + try: + data = json.loads(manifest_path.read_text()) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(data, dict): + continue + version = data.get("version") + if isinstance(version, str) and parse_semver(version) is not None: + catalog[plugin_dir.name] = version + + return catalog + + +def diff_outdated( + installed: dict[str, str], catalog: dict[str, str] +) -> dict[str, tuple[str, str]]: + """Return {plugin_name: (installed_version, catalog_version)} for each + plugin where the catalog version is strictly newer than installed. + + Plugins missing from the catalog, or with unparsable versions on + either side, are skipped rather than flagged. + """ + outdated: dict[str, tuple[str, str]] = {} + for name, installed_version in installed.items(): + catalog_version = catalog.get(name) + if catalog_version is None: + continue + installed_parsed = parse_semver(installed_version) + catalog_parsed = parse_semver(catalog_version) + if installed_parsed is None or catalog_parsed is None: + continue + if catalog_parsed > installed_parsed: + outdated[name] = (installed_version, catalog_version) + return outdated + + +def format_context(outdated: dict[str, tuple[str, str]]) -> str: + """Build the additionalContext message describing outdated plugins.""" + lines = [ + "agentic-primitives plugin update check: " + f"{len(outdated)} installed plugin(s) have newer versions available.", + ] + for name, (installed_version, catalog_version) in sorted(outdated.items()): + lines.append(f" - {name}: {installed_version} -> {catalog_version}") + lines.append( + "Mention this to the user near the start of the conversation and ask " + "if they'd like to update. If they agree, run " + "`claude plugin update @agentic-primitives` for each plugin " + "they approve. Never run a plugin update without the user explicitly " + "agreeing first." + ) + return "\n".join(lines) + + +def is_release_old_enough( + commit_date_iso: str | None, now: datetime, min_age_hours: int = 48 +) -> bool: + """True if commit_date_iso represents a timestamp at least + min_age_hours before now. + + Returns False (fail-safe) if commit_date_iso is None or unparseable. + This backs a supply-chain-safety gate: an unknown release age must + never be treated as "old enough to recommend." + """ + if not commit_date_iso: + return False + try: + commit_date = datetime.fromisoformat(commit_date_iso.replace("Z", "+00:00")) + except (ValueError, TypeError): + return False + if commit_date.tzinfo is None: + commit_date = commit_date.replace(tzinfo=timezone.utc) + return now - commit_date >= timedelta(hours=min_age_hours) diff --git a/tests/unit/claude/hooks/test_plugin_doctor.py b/tests/unit/claude/hooks/test_plugin_doctor.py new file mode 100644 index 00000000..afb4bd65 --- /dev/null +++ b/tests/unit/claude/hooks/test_plugin_doctor.py @@ -0,0 +1,768 @@ +#!/usr/bin/env python3 +""" +Tests for the plugin-doctor plugin. + +Covers: +- freshness.py pure functions: state I/O, cadence gating, semver + comparison, and context-message formatting. +- session-start.py handler: end-to-end via subprocess with env-var + path overrides, matching the run_handler() pattern in test_hooks.py. +""" + +import importlib.util +import json +import os +import subprocess +import sys +import threading +from datetime import datetime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +PROJECT_ROOT = Path(__file__).parent.parent.parent.parent.parent +PLUGIN_DOCTOR = PROJECT_ROOT / "plugins" / "plugin-doctor" +PLUGIN_DOCTOR_LIB = PLUGIN_DOCTOR / "hooks" / "lib" +PLUGIN_DOCTOR_HANDLERS = PLUGIN_DOCTOR / "hooks" / "handlers" + + +def load_freshness(): + """Load freshness.py directly, bypassing package/import-path setup.""" + module_path = PLUGIN_DOCTOR_LIB / "freshness.py" + spec = importlib.util.spec_from_file_location("plugin_doctor_freshness", module_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def run_session_start(env_overrides: dict) -> dict: + """Run session-start.py as a subprocess with path overrides. + + PLUGIN_DOCTOR_SKIP_REFRESH defaults to "1" so tests never shell out + to the real `claude` CLI or touch the network. + """ + handler_path = PLUGIN_DOCTOR_HANDLERS / "session-start.py" + env = dict(os.environ) + env.update(env_overrides) + env.setdefault("PLUGIN_DOCTOR_SKIP_REFRESH", "1") + + result = subprocess.run( + [sys.executable, str(handler_path)], + input=json.dumps({"session_id": "test"}).encode(), + capture_output=True, + env=env, + timeout=5, + ) + + stdout = result.stdout.decode().strip() + if not stdout: + return {} + return json.loads(stdout) + + +class _FixtureGitHubHandler(BaseHTTPRequestHandler): + """Serves canned GitHub commits-API responses for a single test. + + Subclassed per-test via start_fixture_server() with a `responses` + dict mapping the "sha" query param value (e.g. "sdlc/v1.5.0") to + (status_code, json_body). A sha with no matching entry gets a 404, + matching GitHub's real behavior for a nonexistent ref. + """ + + responses: dict = {} + + def do_GET(self): + query = parse_qs(urlparse(self.path).query) + sha = query.get("sha", [None])[0] + if sha in self.responses: + status, body = self.responses[sha] + else: + status, body = 404, {"message": "Not Found"} + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(body).encode()) + + def log_message(self, format, *args): + pass # silence default request logging to stderr + + +def start_fixture_server(responses: dict) -> tuple[str, HTTPServer]: + """Start a background HTTP server for one test. + + Returns (base_url, server). Caller must call server.shutdown() (and + server.server_close(), which shutdown() triggers via the thread + target) when done -- a `try/finally` around the test body is the + simplest way to guarantee that. + """ + handler_class = type("_Handler", (_FixtureGitHubHandler,), {"responses": responses}) + server = HTTPServer(("127.0.0.1", 0), handler_class) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return f"http://127.0.0.1:{server.server_port}", server + + +# ============================================================================ +# Task 1: State I/O and cadence gating +# ============================================================================ + + +class TestStateIO: + def test_read_state_missing_file_returns_empty_dict(self, tmp_path): + freshness = load_freshness() + result = freshness.read_state(tmp_path / "does-not-exist" / "state.json") + assert result == {} + + def test_read_state_malformed_json_returns_empty_dict(self, tmp_path): + freshness = load_freshness() + state_path = tmp_path / "state.json" + state_path.write_text("{not valid json") + assert freshness.read_state(state_path) == {} + + def test_write_state_then_read_state_roundtrips(self, tmp_path): + freshness = load_freshness() + state_path = tmp_path / "nested" / "state.json" + freshness.write_state(state_path, {"last_checked_at": "2026-07-16T00:00:00+00:00"}) + assert freshness.read_state(state_path) == { + "last_checked_at": "2026-07-16T00:00:00+00:00" + } + + def test_read_state_non_dict_json_array_returns_empty_dict(self, tmp_path): + freshness = load_freshness() + state_path = tmp_path / "state.json" + state_path.write_text("[1, 2, 3]") + assert freshness.read_state(state_path) == {} + + def test_read_state_non_dict_json_null_returns_empty_dict(self, tmp_path): + freshness = load_freshness() + state_path = tmp_path / "state.json" + state_path.write_text("null") + assert freshness.read_state(state_path) == {} + + +class TestIsCheckDue: + def test_missing_last_checked_at_is_due(self): + freshness = load_freshness() + now = datetime(2026, 7, 16, tzinfo=timezone.utc) + assert freshness.is_check_due(None, now) is True + + def test_malformed_last_checked_at_is_due(self): + freshness = load_freshness() + now = datetime(2026, 7, 16, tzinfo=timezone.utc) + assert freshness.is_check_due("not-a-date", now) is True + + def test_recent_check_is_not_due(self): + freshness = load_freshness() + now = datetime(2026, 7, 16, tzinfo=timezone.utc) + last = (now - timedelta(days=3)).isoformat() + assert freshness.is_check_due(last, now) is False + + def test_check_exactly_at_interval_is_due(self): + freshness = load_freshness() + now = datetime(2026, 7, 16, tzinfo=timezone.utc) + last = (now - timedelta(days=7)).isoformat() + assert freshness.is_check_due(last, now) is True + + def test_check_older_than_interval_is_due(self): + freshness = load_freshness() + now = datetime(2026, 7, 16, tzinfo=timezone.utc) + last = (now - timedelta(days=10)).isoformat() + assert freshness.is_check_due(last, now) is True + + def test_non_string_last_checked_at_int_is_due(self): + freshness = load_freshness() + now = datetime(2026, 7, 16, tzinfo=timezone.utc) + assert freshness.is_check_due(12345, now) is True + + def test_non_string_last_checked_at_list_is_due(self): + freshness = load_freshness() + now = datetime(2026, 7, 16, tzinfo=timezone.utc) + assert freshness.is_check_due([1, 2, 3], now) is True + + +# ============================================================================ +# Task 2: Semver comparison and context formatting +# ============================================================================ + + +class TestParseSemver: + def test_parses_standard_version(self): + freshness = load_freshness() + assert freshness.parse_semver("1.4.0") == (1, 4, 0) + + def test_parses_version_with_prerelease_suffix(self): + freshness = load_freshness() + assert freshness.parse_semver("1.4.0-beta") == (1, 4, 0) + + def test_returns_none_for_non_semver_string(self): + freshness = load_freshness() + assert freshness.parse_semver("unknown") is None + + def test_returns_none_for_non_string_input(self): + freshness = load_freshness() + assert freshness.parse_semver(None) is None + + +class TestGetInstalledVersions: + def test_missing_cache_root_returns_empty_dict(self, tmp_path): + freshness = load_freshness() + assert freshness.get_installed_versions(tmp_path / "nope") == {} + + def test_reads_single_version_per_plugin(self, tmp_path): + freshness = load_freshness() + (tmp_path / "sdlc" / "1.4.0").mkdir(parents=True) + (tmp_path / "meta" / "1.3.0").mkdir(parents=True) + assert freshness.get_installed_versions(tmp_path) == { + "sdlc": "1.4.0", + "meta": "1.3.0", + } + + def test_picks_highest_version_when_multiple_present(self, tmp_path): + freshness = load_freshness() + (tmp_path / "sdlc" / "1.3.0").mkdir(parents=True) + (tmp_path / "sdlc" / "1.4.0").mkdir(parents=True) + assert freshness.get_installed_versions(tmp_path) == {"sdlc": "1.4.0"} + + def test_skips_non_semver_version_directories(self, tmp_path): + freshness = load_freshness() + (tmp_path / "sdlc" / "unknown").mkdir(parents=True) + assert freshness.get_installed_versions(tmp_path) == {} + + def test_semver_ordering_1_10_0_beats_1_9_0(self, tmp_path): + """Regression test for Finding 2: verify 1.10.0 > 1.9.0 by semver, not string. + String comparison would incorrectly pick '1.9.0' (lexicographically), but + tuple comparison (semver) correctly picks '1.10.0'.""" + freshness = load_freshness() + (tmp_path / "sdlc" / "1.9.0").mkdir(parents=True) + (tmp_path / "sdlc" / "1.10.0").mkdir(parents=True) + assert freshness.get_installed_versions(tmp_path) == {"sdlc": "1.10.0"} + + +class TestGetCatalogVersions: + def test_missing_marketplace_root_returns_empty_dict(self, tmp_path): + freshness = load_freshness() + assert freshness.get_catalog_versions(tmp_path / "nope") == {} + + def test_reads_version_from_plugin_json(self, tmp_path): + freshness = load_freshness() + manifest_dir = tmp_path / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text( + json.dumps({"name": "sdlc", "version": "1.5.0"}) + ) + assert freshness.get_catalog_versions(tmp_path) == {"sdlc": "1.5.0"} + + def test_skips_plugin_missing_manifest(self, tmp_path): + freshness = load_freshness() + (tmp_path / "sdlc").mkdir(parents=True) + assert freshness.get_catalog_versions(tmp_path) == {} + + def test_skips_malformed_manifest(self, tmp_path): + freshness = load_freshness() + manifest_dir = tmp_path / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text("{not valid json") + assert freshness.get_catalog_versions(tmp_path) == {} + + def test_skips_valid_json_array_manifest_but_reads_other_plugins(self, tmp_path): + """Regression test for Finding 1: non-dict valid JSON should not crash + or blank out the entire catalog. A structurally valid but wrong-shape + manifest (e.g., []) must not raise AttributeError when calling .get().""" + freshness = load_freshness() + # Create first plugin with valid JSON array (wrong shape, should be skipped) + bad_manifest_dir = tmp_path / "bad-plugin" / ".claude-plugin" + bad_manifest_dir.mkdir(parents=True) + (bad_manifest_dir / "plugin.json").write_text("[]") + # Create second plugin with valid manifest + good_manifest_dir = tmp_path / "good-plugin" / ".claude-plugin" + good_manifest_dir.mkdir(parents=True) + (good_manifest_dir / "plugin.json").write_text( + json.dumps({"name": "good-plugin", "version": "1.5.0"}) + ) + # Should return only the good plugin and NOT raise + assert freshness.get_catalog_versions(tmp_path) == {"good-plugin": "1.5.0"} + + def test_skips_valid_json_null_manifest_but_reads_other_plugins(self, tmp_path): + """Regression test for Finding 1 with null: non-dict valid JSON should not + crash. A manifest containing just `null` must not raise AttributeError.""" + freshness = load_freshness() + # Create first plugin with valid JSON null (wrong shape) + bad_manifest_dir = tmp_path / "null-plugin" / ".claude-plugin" + bad_manifest_dir.mkdir(parents=True) + (bad_manifest_dir / "plugin.json").write_text("null") + # Create second plugin with valid manifest + good_manifest_dir = tmp_path / "good-plugin" / ".claude-plugin" + good_manifest_dir.mkdir(parents=True) + (good_manifest_dir / "plugin.json").write_text( + json.dumps({"name": "good-plugin", "version": "2.0.0"}) + ) + # Should return only the good plugin and NOT raise + assert freshness.get_catalog_versions(tmp_path) == {"good-plugin": "2.0.0"} + + +class TestDiffOutdated: + def test_flags_plugin_behind_catalog(self): + freshness = load_freshness() + outdated = freshness.diff_outdated({"sdlc": "1.4.0"}, {"sdlc": "1.5.0"}) + assert outdated == {"sdlc": ("1.4.0", "1.5.0")} + + def test_up_to_date_plugin_not_flagged(self): + freshness = load_freshness() + assert freshness.diff_outdated({"sdlc": "1.4.0"}, {"sdlc": "1.4.0"}) == {} + + def test_plugin_missing_from_catalog_not_flagged(self): + freshness = load_freshness() + assert freshness.diff_outdated({"local-only": "0.1.0"}, {}) == {} + + def test_unparsable_versions_not_flagged(self): + freshness = load_freshness() + assert freshness.diff_outdated({"sdlc": "unknown"}, {"sdlc": "1.5.0"}) == {} + + def test_semver_ordering_1_9_0_lt_1_10_0_flagged_outdated(self): + """Regression test for Finding 2: 1.9.0 should be flagged as outdated + when catalog is 1.10.0. Tuple comparison (semver) correctly handles this; + string comparison would incorrectly say 1.9.0 >= 1.10.0 lexicographically.""" + freshness = load_freshness() + outdated = freshness.diff_outdated({"sdlc": "1.9.0"}, {"sdlc": "1.10.0"}) + assert outdated == {"sdlc": ("1.9.0", "1.10.0")} + + def test_semver_ordering_1_10_0_not_outdated_vs_1_9_0(self): + """Regression test for Finding 2: 1.10.0 should NOT be flagged as outdated + when catalog is 1.9.0. Verifies the comparison direction is correct.""" + freshness = load_freshness() + outdated = freshness.diff_outdated({"sdlc": "1.10.0"}, {"sdlc": "1.9.0"}) + assert outdated == {} + + +class TestFormatContext: + def test_lists_each_outdated_plugin_with_versions(self): + freshness = load_freshness() + message = freshness.format_context({"sdlc": ("1.4.0", "1.5.0")}) + assert "sdlc: 1.4.0 -> 1.5.0" in message + + def test_instructs_claude_to_ask_before_updating(self): + freshness = load_freshness() + message = freshness.format_context({"sdlc": ("1.4.0", "1.5.0")}) + assert "ask" in message.lower() + assert ( + "never run a plugin update without the user explicitly agreeing" + in message.lower() + ) + + +# ============================================================================ +# Release-age gate: is_release_old_enough (pure function) +# ============================================================================ + + +class TestIsReleaseOldEnough: + def test_none_input_is_not_old_enough(self): + freshness = load_freshness() + now = datetime(2026, 7, 17, tzinfo=timezone.utc) + assert freshness.is_release_old_enough(None, now) is False + + def test_unparseable_date_is_not_old_enough(self): + freshness = load_freshness() + now = datetime(2026, 7, 17, tzinfo=timezone.utc) + assert freshness.is_release_old_enough("not-a-date", now) is False + + def test_recent_commit_is_not_old_enough(self): + freshness = load_freshness() + now = datetime(2026, 7, 17, tzinfo=timezone.utc) + commit_date = (now - timedelta(hours=10)).isoformat().replace("+00:00", "Z") + assert freshness.is_release_old_enough(commit_date, now) is False + + def test_commit_exactly_48_hours_old_is_old_enough(self): + freshness = load_freshness() + now = datetime(2026, 7, 17, tzinfo=timezone.utc) + commit_date = (now - timedelta(hours=48)).isoformat().replace("+00:00", "Z") + assert freshness.is_release_old_enough(commit_date, now) is True + + def test_commit_older_than_48_hours_is_old_enough(self): + freshness = load_freshness() + now = datetime(2026, 7, 17, tzinfo=timezone.utc) + commit_date = (now - timedelta(hours=72)).isoformat().replace("+00:00", "Z") + assert freshness.is_release_old_enough(commit_date, now) is True + + def test_custom_min_age_hours(self): + freshness = load_freshness() + now = datetime(2026, 7, 17, tzinfo=timezone.utc) + commit_date = (now - timedelta(hours=5)).isoformat().replace("+00:00", "Z") + assert freshness.is_release_old_enough(commit_date, now, min_age_hours=1) is True + + +# ============================================================================ +# Task 3: session-start.py handler (end-to-end via subprocess) +# ============================================================================ + + +class TestSessionStartHandler: + def test_no_context_when_all_plugins_up_to_date(self, tmp_path): + cache_root = tmp_path / "cache" + (cache_root / "sdlc" / "1.4.0").mkdir(parents=True) + marketplace_root = tmp_path / "marketplace" + manifest_dir = marketplace_root / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text(json.dumps({"version": "1.4.0"})) + state_path = tmp_path / "state.json" + state_path.write_text( + json.dumps({"last_checked_at": datetime.now(timezone.utc).isoformat()}) + ) + + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + } + ) + assert result == {} + + def test_outdated_plugin_old_enough_release_is_surfaced(self, tmp_path): + cache_root = tmp_path / "cache" + (cache_root / "sdlc" / "1.4.0").mkdir(parents=True) + marketplace_root = tmp_path / "marketplace" + manifest_dir = marketplace_root / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text(json.dumps({"version": "1.5.0"})) + state_path = tmp_path / "state.json" # missing -> due + + old_commit_date = ( + (datetime.now(timezone.utc) - timedelta(hours=72)) + .isoformat() + .replace("+00:00", "Z") + ) + base_url, server = start_fixture_server( + {"sdlc/v1.5.0": (200, [{"commit": {"committer": {"date": old_commit_date}}}])} + ) + try: + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + "PLUGIN_DOCTOR_GITHUB_API_BASE": base_url, + } + ) + finally: + server.shutdown() + + assert result["hookSpecificOutput"]["hookEventName"] == "SessionStart" + assert ( + "sdlc: 1.4.0 -> 1.5.0" in result["hookSpecificOutput"]["additionalContext"] + ) + written = json.loads(state_path.read_text()) + assert written["release_ages"]["sdlc"] == {"version": "1.5.0", "old_enough": True} + + def test_outdated_plugin_too_recent_release_is_suppressed(self, tmp_path): + cache_root = tmp_path / "cache" + (cache_root / "sdlc" / "1.4.0").mkdir(parents=True) + marketplace_root = tmp_path / "marketplace" + manifest_dir = marketplace_root / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text(json.dumps({"version": "1.5.0"})) + state_path = tmp_path / "state.json" + + recent_commit_date = ( + (datetime.now(timezone.utc) - timedelta(hours=5)) + .isoformat() + .replace("+00:00", "Z") + ) + base_url, server = start_fixture_server( + { + "sdlc/v1.5.0": ( + 200, + [{"commit": {"committer": {"date": recent_commit_date}}}], + ) + } + ) + try: + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + "PLUGIN_DOCTOR_GITHUB_API_BASE": base_url, + } + ) + finally: + server.shutdown() + + assert result == {} + written = json.loads(state_path.read_text()) + assert written["release_ages"]["sdlc"] == {"version": "1.5.0", "old_enough": False} + + def test_outdated_plugin_untagged_version_is_suppressed(self, tmp_path): + cache_root = tmp_path / "cache" + (cache_root / "sdlc" / "1.4.0").mkdir(parents=True) + marketplace_root = tmp_path / "marketplace" + manifest_dir = marketplace_root / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text(json.dumps({"version": "1.5.0"})) + state_path = tmp_path / "state.json" + + base_url, server = start_fixture_server({}) # no matching sha -> 404 + try: + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + "PLUGIN_DOCTOR_GITHUB_API_BASE": base_url, + } + ) + finally: + server.shutdown() + + assert result == {} + written = json.loads(state_path.read_text()) + assert written["release_ages"]["sdlc"]["old_enough"] is False + + def test_outdated_plugin_unreachable_github_is_suppressed(self, tmp_path): + cache_root = tmp_path / "cache" + (cache_root / "sdlc" / "1.4.0").mkdir(parents=True) + marketplace_root = tmp_path / "marketplace" + manifest_dir = marketplace_root / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text(json.dumps({"version": "1.5.0"})) + state_path = tmp_path / "state.json" + + closed_server = HTTPServer(("127.0.0.1", 0), _FixtureGitHubHandler) + unreachable_url = f"http://127.0.0.1:{closed_server.server_port}" + closed_server.server_close() # bound and closed -- nothing is listening + + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + "PLUGIN_DOCTOR_GITHUB_API_BASE": unreachable_url, + } + ) + assert result == {} + + def test_stale_release_ages_entry_for_old_version_is_not_reused(self, tmp_path): + cache_root = tmp_path / "cache" + (cache_root / "sdlc" / "1.4.0").mkdir(parents=True) + marketplace_root = tmp_path / "marketplace" + manifest_dir = marketplace_root / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text(json.dumps({"version": "1.6.0"})) + state_path = tmp_path / "state.json" + # Recent last_checked_at -> NOT due, so no fresh age-check runs this + # session. The cached entry is for 1.5.0, but the catalog now shows + # 1.6.0 -- a version the cache never verified. + state_path.write_text( + json.dumps( + { + "last_checked_at": datetime.now(timezone.utc).isoformat(), + "release_ages": {"sdlc": {"version": "1.5.0", "old_enough": True}}, + } + ) + ) + + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + } + ) + assert result == {} + + def test_non_due_session_reuses_cached_release_ages_without_network_call( + self, tmp_path + ): + cache_root = tmp_path / "cache" + (cache_root / "sdlc" / "1.4.0").mkdir(parents=True) + marketplace_root = tmp_path / "marketplace" + manifest_dir = marketplace_root / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text(json.dumps({"version": "1.5.0"})) + state_path = tmp_path / "state.json" + state_path.write_text( + json.dumps( + { + "last_checked_at": datetime.now(timezone.utc).isoformat(), # NOT due + "release_ages": {"sdlc": {"version": "1.5.0", "old_enough": True}}, + } + ) + ) + + # Point at an unreachable GitHub API base. If the handler tried to + # hit the network here, the fetch would fail and (per the fail-safe + # rule) old_enough would come back False -- suppressing the result. + # Asserting the cached True value is still honored proves the + # handler never attempted a network call on this non-due session. + closed_server = HTTPServer(("127.0.0.1", 0), _FixtureGitHubHandler) + unreachable_url = f"http://127.0.0.1:{closed_server.server_port}" + closed_server.server_close() + + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + "PLUGIN_DOCTOR_GITHUB_API_BASE": unreachable_url, + } + ) + assert result["hookSpecificOutput"]["hookEventName"] == "SessionStart" + assert ( + "sdlc: 1.4.0 -> 1.5.0" in result["hookSpecificOutput"]["additionalContext"] + ) + + def test_due_session_recomputes_release_ages_dropping_stale_entries( + self, tmp_path + ): + # sdlc is now up to date, but state still has a stale release_ages + # entry for it from a previous (now-resolved) outdated check. + cache_root = tmp_path / "cache" + (cache_root / "sdlc" / "1.5.0").mkdir(parents=True) + marketplace_root = tmp_path / "marketplace" + manifest_dir = marketplace_root / "sdlc" / ".claude-plugin" + manifest_dir.mkdir(parents=True) + (manifest_dir / "plugin.json").write_text(json.dumps({"version": "1.5.0"})) + state_path = tmp_path / "state.json" + old_timestamp = ( + datetime.now(timezone.utc) - timedelta(days=10) + ).isoformat() # due + state_path.write_text( + json.dumps( + { + "last_checked_at": old_timestamp, + "release_ages": {"sdlc": {"version": "1.4.0", "old_enough": True}}, + } + ) + ) + + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + } + ) + assert result == {} + written = json.loads(state_path.read_text()) + assert written["release_ages"] == {} + + def test_writes_state_when_check_is_due(self, tmp_path): + cache_root = tmp_path / "cache" + cache_root.mkdir() + marketplace_root = tmp_path / "marketplace" + marketplace_root.mkdir() + state_path = tmp_path / "state.json" # does not exist yet -> due + + run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + } + ) + assert state_path.exists() + assert "last_checked_at" in json.loads(state_path.read_text()) + + def test_does_not_touch_state_when_check_not_due(self, tmp_path): + cache_root = tmp_path / "cache" + cache_root.mkdir() + marketplace_root = tmp_path / "marketplace" + marketplace_root.mkdir() + state_path = tmp_path / "state.json" + original = {"last_checked_at": datetime.now(timezone.utc).isoformat()} + state_path.write_text(json.dumps(original)) + + run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(cache_root), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(marketplace_root), + "PLUGIN_DOCTOR_STATE_PATH": str(state_path), + } + ) + assert json.loads(state_path.read_text()) == original + + def test_missing_cache_and_marketplace_dirs_no_crash(self, tmp_path): + result = run_session_start( + { + "PLUGIN_DOCTOR_CACHE_DIR": str(tmp_path / "no-cache"), + "PLUGIN_DOCTOR_MARKETPLACE_DIR": str(tmp_path / "no-marketplace"), + "PLUGIN_DOCTOR_STATE_PATH": str(tmp_path / "state.json"), + } + ) + assert result == {} + + def test_refresh_subprocess_is_actually_invoked_when_due(self, tmp_path): + """Prove _refresh_marketplace() really calls subprocess.run(["claude", ...]) + by putting a fake `claude` executable on PATH and checking it ran.""" + fake_bin_dir = tmp_path / "fake-bin" + fake_bin_dir.mkdir() + marker_path = tmp_path / "claude-was-invoked.marker" + fake_claude = fake_bin_dir / "claude" + fake_claude.write_text(f"#!/bin/sh\ntouch {marker_path}\nexit 0\n") + fake_claude.chmod(0o755) + + cache_root = tmp_path / "cache" + cache_root.mkdir() + marketplace_root = tmp_path / "marketplace" + marketplace_root.mkdir() + state_path = tmp_path / "state.json" + old_timestamp = (datetime.now(timezone.utc) - timedelta(days=10)).isoformat() + state_path.write_text(json.dumps({"last_checked_at": old_timestamp})) + + handler_path = PLUGIN_DOCTOR_HANDLERS / "session-start.py" + env = dict(os.environ) + env["PATH"] = f"{fake_bin_dir}:{env['PATH']}" + env["PLUGIN_DOCTOR_CACHE_DIR"] = str(cache_root) + env["PLUGIN_DOCTOR_MARKETPLACE_DIR"] = str(marketplace_root) + env["PLUGIN_DOCTOR_STATE_PATH"] = str(state_path) + env["PLUGIN_DOCTOR_SKIP_REFRESH"] = "0" + + result = subprocess.run( + [sys.executable, str(handler_path)], + input=json.dumps({"session_id": "test"}).encode(), + capture_output=True, + env=env, + timeout=5, + ) + + assert result.returncode == 0 + assert marker_path.exists() + + def test_missing_claude_executable_fails_open(self, tmp_path): + """When `claude` isn't on PATH, subprocess.run raises FileNotFoundError + (a subclass of OSError); _refresh_marketplace must swallow it and the + handler must still complete without crashing.""" + empty_bin_dir = tmp_path / "empty-bin" + empty_bin_dir.mkdir() + + cache_root = tmp_path / "cache" + cache_root.mkdir() + marketplace_root = tmp_path / "marketplace" + marketplace_root.mkdir() + state_path = tmp_path / "state.json" + # No state file -> is_check_due() returns True. + + handler_path = PLUGIN_DOCTOR_HANDLERS / "session-start.py" + env = dict(os.environ) + env["PATH"] = str(empty_bin_dir) + env["PLUGIN_DOCTOR_CACHE_DIR"] = str(cache_root) + env["PLUGIN_DOCTOR_MARKETPLACE_DIR"] = str(marketplace_root) + env["PLUGIN_DOCTOR_STATE_PATH"] = str(state_path) + env["PLUGIN_DOCTOR_SKIP_REFRESH"] = "0" + + result = subprocess.run( + [sys.executable, str(handler_path)], + input=json.dumps({"session_id": "test"}).encode(), + capture_output=True, + env=env, + timeout=5, + ) + + assert result.returncode == 0 + stdout = result.stdout.decode().strip() + if stdout: + parsed = json.loads(stdout) + assert isinstance(parsed, dict) + # State should still have been written despite the refresh failure. + assert state_path.exists() + assert "last_checked_at" in json.loads(state_path.read_text())