From 898407e686761023beadb34035f906ae70697924 Mon Sep 17 00:00:00 2001 From: NeuralEmpowerment <129192050+NeuralEmpowerment@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:04:24 -0700 Subject: [PATCH 01/17] docs: add plugin-doctor design spec Design for a new plugin that warns when installed agentic-primitives plugins have updates available, checked weekly via filesystem comparison against the marketplace cache, never auto-updating. --- .../specs/2026-07-16-plugin-doctor-design.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-16-plugin-doctor-design.md 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..ca46d984 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-plugin-doctor-design.md @@ -0,0 +1,104 @@ +# 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. +- Surfaces outdated 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. If any are behind, 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. +5. If nothing is outdated, or the check wasn't due this session, emit nothing — no-op, matching the convention of every other hook in this repo (empty output = allow/no context). + +### 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. + +### 5. 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`). From 5d52cd13b5808345270650c10c8e606589f178b6 Mon Sep 17 00:00:00 2001 From: NeuralEmpowerment <129192050+NeuralEmpowerment@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:20:59 -0700 Subject: [PATCH 02/17] docs: add plugin-doctor implementation plan TDD task breakdown for the plugin-doctor design: state/cadence functions, semver comparison, the SessionStart handler, then marketplace registration and docs. --- ...2026-07-16-plugin-doctor-implementation.md | 932 ++++++++++++++++++ 1 file changed, 932 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-plugin-doctor-implementation.md 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. From 43b11f08b40cb169d604e7449a2a859ccf094536 Mon Sep 17 00:00:00 2001 From: NeuralEmpowerment <129192050+NeuralEmpowerment@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:49:40 -0700 Subject: [PATCH 03/17] chore: ignore local .worktrees/ directory --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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__/ From 0b313f65684abc16e0471b9ddbe8b61bb7ec5e3f Mon Sep 17 00:00:00 2001 From: NeuralEmpowerment <129192050+NeuralEmpowerment@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:51:40 -0700 Subject: [PATCH 04/17] feat(plugin-doctor): add state I/O and cadence gating --- plugins/plugin-doctor/hooks/lib/freshness.py | 43 +++++++ tests/unit/claude/hooks/test_plugin_doctor.py | 113 ++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 plugins/plugin-doctor/hooks/lib/freshness.py create mode 100644 tests/unit/claude/hooks/test_plugin_doctor.py diff --git a/plugins/plugin-doctor/hooks/lib/freshness.py b/plugins/plugin-doctor/hooks/lib/freshness.py new file mode 100644 index 00000000..b4cd56ee --- /dev/null +++ b/plugins/plugin-doctor/hooks/lib/freshness.py @@ -0,0 +1,43 @@ +""" +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) 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..43aab459 --- /dev/null +++ b/tests/unit/claude/hooks/test_plugin_doctor.py @@ -0,0 +1,113 @@ +#!/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 From fcc4416e7bd6de50c6190999b2fb62229defe1dc Mon Sep 17 00:00:00 2001 From: NeuralEmpowerment <129192050+NeuralEmpowerment@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:54:34 -0700 Subject: [PATCH 05/17] fix(plugin-doctor): fail open on non-dict state and non-string timestamps - read_state() now validates that parsed JSON is a dict; non-dict values (lists, null, etc.) return {} to match fail-open semantics - is_check_due() now catches both ValueError and TypeError from datetime.fromisoformat(), preventing crashes when last_checked_at is a non-string type (int, list, etc.) - Add 4 regression tests: 2 for non-dict JSON (list and null), 2 for non-string timestamps (int and list) --- plugins/plugin-doctor/hooks/lib/freshness.py | 7 ++++-- tests/unit/claude/hooks/test_plugin_doctor.py | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/plugins/plugin-doctor/hooks/lib/freshness.py b/plugins/plugin-doctor/hooks/lib/freshness.py index b4cd56ee..8dd73bce 100644 --- a/plugins/plugin-doctor/hooks/lib/freshness.py +++ b/plugins/plugin-doctor/hooks/lib/freshness.py @@ -16,7 +16,10 @@ def read_state(state_path: Path) -> dict: """Read the state file, returning {} if missing or malformed.""" try: - return json.loads(state_path.read_text()) + data = json.loads(state_path.read_text()) + if not isinstance(data, dict): + return {} + return data except (OSError, json.JSONDecodeError): return {} @@ -36,7 +39,7 @@ def is_check_due( return True try: last = datetime.fromisoformat(last_checked_at) - except ValueError: + except (ValueError, TypeError): return True if last.tzinfo is None: last = last.replace(tzinfo=timezone.utc) diff --git a/tests/unit/claude/hooks/test_plugin_doctor.py b/tests/unit/claude/hooks/test_plugin_doctor.py index 43aab459..baf7249f 100644 --- a/tests/unit/claude/hooks/test_plugin_doctor.py +++ b/tests/unit/claude/hooks/test_plugin_doctor.py @@ -82,6 +82,18 @@ def test_write_state_then_read_state_roundtrips(self, tmp_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): @@ -111,3 +123,13 @@ def test_check_older_than_interval_is_due(self): 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 From 8e280fdd38c815965dd71e6c141dce99e968cba3 Mon Sep 17 00:00:00 2001 From: NeuralEmpowerment <129192050+NeuralEmpowerment@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:57:58 -0700 Subject: [PATCH 06/17] feat(plugin-doctor): add semver comparison and context formatting --- plugins/plugin-doctor/hooks/lib/freshness.py | 113 ++++++++++++++++++ tests/unit/claude/hooks/test_plugin_doctor.py | 111 +++++++++++++++++ 2 files changed, 224 insertions(+) diff --git a/plugins/plugin-doctor/hooks/lib/freshness.py b/plugins/plugin-doctor/hooks/lib/freshness.py index 8dd73bce..1805ce80 100644 --- a/plugins/plugin-doctor/hooks/lib/freshness.py +++ b/plugins/plugin-doctor/hooks/lib/freshness.py @@ -9,6 +9,7 @@ """ import json +import re from datetime import datetime, timedelta, timezone from pathlib import Path @@ -44,3 +45,115 @@ def is_check_due( 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 + 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) diff --git a/tests/unit/claude/hooks/test_plugin_doctor.py b/tests/unit/claude/hooks/test_plugin_doctor.py index baf7249f..f03dc3e0 100644 --- a/tests/unit/claude/hooks/test_plugin_doctor.py +++ b/tests/unit/claude/hooks/test_plugin_doctor.py @@ -133,3 +133,114 @@ 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) == {} + + +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() + ) From 5bb50926bf215bccc3a0c34964d79d3fedb90373 Mon Sep 17 00:00:00 2001 From: NeuralEmpowerment <129192050+NeuralEmpowerment@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:01:43 -0700 Subject: [PATCH 07/17] feat(plugin-doctor): add SessionStart handler and plugin manifest --- .../plugin-doctor/.claude-plugin/plugin.json | 9 ++ .../hooks/handlers/session-start.py | 116 ++++++++++++++++++ plugins/plugin-doctor/hooks/hooks.json | 17 +++ tests/unit/claude/hooks/test_plugin_doctor.py | 95 ++++++++++++++ 4 files changed, 237 insertions(+) create mode 100644 plugins/plugin-doctor/.claude-plugin/plugin.json create mode 100755 plugins/plugin-doctor/hooks/handlers/session-start.py create mode 100644 plugins/plugin-doctor/hooks/hooks.json 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/hooks/handlers/session-start.py b/plugins/plugin-doctor/hooks/handlers/session-start.py new file mode 100755 index 00000000..279b02dc --- /dev/null +++ b/plugins/plugin-doctor/hooks/handlers/session-start.py @@ -0,0 +1,116 @@ +#!/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() 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/tests/unit/claude/hooks/test_plugin_doctor.py b/tests/unit/claude/hooks/test_plugin_doctor.py index f03dc3e0..20bc98b0 100644 --- a/tests/unit/claude/hooks/test_plugin_doctor.py +++ b/tests/unit/claude/hooks/test_plugin_doctor.py @@ -244,3 +244,98 @@ def test_instructs_claude_to_ask_before_updating(self): "never run a plugin update without the user explicitly agreeing" in message.lower() ) + + +# ============================================================================ +# 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 == {} From 92d19513ed6d3f7e937c7c380437477710fef57b Mon Sep 17 00:00:00 2001 From: NeuralEmpowerment <129192050+NeuralEmpowerment@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:06:30 -0700 Subject: [PATCH 08/17] test(plugin-doctor): cover the marketplace-refresh subprocess path --- tests/unit/claude/hooks/test_plugin_doctor.py | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/unit/claude/hooks/test_plugin_doctor.py b/tests/unit/claude/hooks/test_plugin_doctor.py index 20bc98b0..dea55562 100644 --- a/tests/unit/claude/hooks/test_plugin_doctor.py +++ b/tests/unit/claude/hooks/test_plugin_doctor.py @@ -339,3 +339,79 @@ def test_missing_cache_and_marketplace_dirs_no_crash(self, tmp_path): } ) 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()) From 046f6fcd1c526146a8532feff91dd330a605a1d4 Mon Sep 17 00:00:00 2001 From: NeuralEmpowerment <129192050+NeuralEmpowerment@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:10:19 -0700 Subject: [PATCH 09/17] docs(plugin-doctor): register plugin and document --- .claude-plugin/marketplace.json | 6 ++++++ CHANGELOG.md | 9 +++++++++ README.md | 2 ++ plugins/plugin-doctor/CHANGELOG.md | 5 +++++ plugins/plugin-doctor/README.md | 29 +++++++++++++++++++++++++++++ 5 files changed, 51 insertions(+) create mode 100644 plugins/plugin-doctor/CHANGELOG.md create mode 100644 plugins/plugin-doctor/README.md 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/CHANGELOG.md b/CHANGELOG.md index 45659fa3..29e49181 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. +- **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. + ### 🏗 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/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 +``` From b276a4cdd7f9feb1b1d68b18a11ddd99e82a97b6 Mon Sep 17 00:00:00 2001 From: NeuralEmpowerment <129192050+NeuralEmpowerment@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:16:43 -0700 Subject: [PATCH 10/17] docs: fix stale test count in plugin-doctor changelog entry Final review caught the count still said 26 after the Task 1/3 fix rounds added 11 more tests (37 total). --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29e49181..bbfcd2a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ A new plugin that warns when installed `agentic-primitives` plugins have updates #### 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. +- **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) From ed7243bd69bc08dbb4a92f2ca97b82f26d63de35 Mon Sep 17 00:00:00 2001 From: NeuralEmpowerment <129192050+NeuralEmpowerment@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:46:26 -0700 Subject: [PATCH 11/17] fix(plugin-doctor): guard catalog manifest against non-dict JSON, add semver ordering tests --- plugins/plugin-doctor/hooks/lib/freshness.py | 2 + tests/unit/claude/hooks/test_plugin_doctor.py | 59 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/plugins/plugin-doctor/hooks/lib/freshness.py b/plugins/plugin-doctor/hooks/lib/freshness.py index 1805ce80..f0a78527 100644 --- a/plugins/plugin-doctor/hooks/lib/freshness.py +++ b/plugins/plugin-doctor/hooks/lib/freshness.py @@ -111,6 +111,8 @@ def get_catalog_versions(marketplace_plugins_root: Path) -> dict[str, str]: 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 diff --git a/tests/unit/claude/hooks/test_plugin_doctor.py b/tests/unit/claude/hooks/test_plugin_doctor.py index dea55562..2bb6d022 100644 --- a/tests/unit/claude/hooks/test_plugin_doctor.py +++ b/tests/unit/claude/hooks/test_plugin_doctor.py @@ -183,6 +183,15 @@ def test_skips_non_semver_version_directories(self, tmp_path): (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): @@ -210,6 +219,41 @@ def test_skips_malformed_manifest(self, tmp_path): (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): @@ -229,6 +273,21 @@ 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): From a9a8d545a3c54c3e5a7279d61db2ccc01a3070d4 Mon Sep 17 00:00:00 2001 From: NeuralEmpowerment <129192050+NeuralEmpowerment@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:46:49 -0700 Subject: [PATCH 12/17] docs: clarify plugin-doctor cadence wording (spec step 5) Codex review flagged step 5's "or the check wasn't due" clause as ambiguous against step 3's "compare every session" requirement. The implementation already does the right thing (compare every session, refresh gated to weekly); this only fixes the spec's wording. --- docs/superpowers/specs/2026-07-16-plugin-doctor-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-07-16-plugin-doctor-design.md b/docs/superpowers/specs/2026-07-16-plugin-doctor-design.md index ca46d984..0ce796d2 100644 --- a/docs/superpowers/specs/2026-07-16-plugin-doctor-design.md +++ b/docs/superpowers/specs/2026-07-16-plugin-doctor-design.md @@ -78,7 +78,7 @@ On `SessionStart`: - 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. -5. If nothing is outdated, or the check wasn't due this session, emit nothing — no-op, matching the convention of every other hook in this repo (empty output = allow/no context). +5. If nothing is outdated, 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 plugins (or report nothing) regardless of whether step 2's refresh fired. ### 4. Error handling From e096d80e3322a311de33f510f65fd3d0ac6d6280 Mon Sep 17 00:00:00 2001 From: NeuralEmpowerment <129192050+NeuralEmpowerment@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:20:19 -0700 Subject: [PATCH 13/17] docs: amend plugin-doctor design with GitHub release-age gate Requested change on PR #267: the original design compared only against the local marketplace cache, which could surface a version the moment it landed with no cooldown -- a supply-chain risk. This adds a 48-hour-minimum-release-age gate, verified directly against GitHub's per-plugin release tags, checked at most weekly and cached in state.json alongside the existing refresh cadence. --- .../specs/2026-07-16-plugin-doctor-design.md | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-07-16-plugin-doctor-design.md b/docs/superpowers/specs/2026-07-16-plugin-doctor-design.md index 0ce796d2..5d10a911 100644 --- a/docs/superpowers/specs/2026-07-16-plugin-doctor-design.md +++ b/docs/superpowers/specs/2026-07-16-plugin-doctor-design.md @@ -10,7 +10,8 @@ That specific case was a vendored copy, not a real plugin install, so it's out o 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. -- Surfaces outdated plugins to the user near the start of a session via Claude, who asks whether to update. +- 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 @@ -74,11 +75,12 @@ On `SessionStart`: - 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. If any are behind, emit `hookSpecificOutput.additionalContext` naming each outdated plugin and its versions (e.g. `sdlc 1.4.0 → 1.5.0`), with an instruction to Claude: +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. -5. If nothing is outdated, 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 plugins (or report nothing) regardless of whether step 2's refresh fired. +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 @@ -91,7 +93,38 @@ Fail silent/open in every case, matching the rest of the hook handlers in this r None of these should ever block a session or print an error the user has to deal with. -### 5. Testing +### 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. @@ -102,3 +135,9 @@ Cases to cover: - 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. From 94cb1555977e6404e1de8234229d7e9417e2052f Mon Sep 17 00:00:00 2001 From: NeuralEmpowerment <129192050+NeuralEmpowerment@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:24:19 -0700 Subject: [PATCH 14/17] docs: add implementation plan for the GitHub release-age gate TDD task breakdown for the 48h supply-chain cooldown: the pure is_release_old_enough comparison, then the GitHub fetch + main() wiring + end-to-end tests via a local HTTP fixture server. --- ...26-07-17-plugin-doctor-release-age-gate.md | 636 ++++++++++++++++++ 1 file changed, 636 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-plugin-doctor-release-age-gate.md 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. From 18c68645b26d0c716247d88d80d768f2c5ddb89f Mon Sep 17 00:00:00 2001 From: NeuralEmpowerment <129192050+NeuralEmpowerment@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:25:57 -0700 Subject: [PATCH 15/17] feat(plugin-doctor): add is_release_old_enough pure function --- plugins/plugin-doctor/hooks/lib/freshness.py | 21 ++++++++++ tests/unit/claude/hooks/test_plugin_doctor.py | 41 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/plugins/plugin-doctor/hooks/lib/freshness.py b/plugins/plugin-doctor/hooks/lib/freshness.py index f0a78527..9d8fb759 100644 --- a/plugins/plugin-doctor/hooks/lib/freshness.py +++ b/plugins/plugin-doctor/hooks/lib/freshness.py @@ -159,3 +159,24 @@ def format_context(outdated: dict[str, tuple[str, str]]) -> str: "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 index 2bb6d022..2375c54b 100644 --- a/tests/unit/claude/hooks/test_plugin_doctor.py +++ b/tests/unit/claude/hooks/test_plugin_doctor.py @@ -305,6 +305,47 @@ def test_instructs_claude_to_ask_before_updating(self): ) +# ============================================================================ +# 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) # ============================================================================ From 3b9ed068d9d7dd1d0c6d1bcaa4f69bbb6f56a2a9 Mon Sep 17 00:00:00 2001 From: NeuralEmpowerment <129192050+NeuralEmpowerment@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:29:32 -0700 Subject: [PATCH 16/17] feat(plugin-doctor): gate outdated-version warnings on a 48h GitHub release age --- .../hooks/handlers/session-start.py | 73 ++++- tests/unit/claude/hooks/test_plugin_doctor.py | 257 +++++++++++++++++- 2 files changed, 320 insertions(+), 10 deletions(-) diff --git a/plugins/plugin-doctor/hooks/handlers/session-start.py b/plugins/plugin-doctor/hooks/handlers/session-start.py index 279b02dc..1f3c4fbd 100755 --- a/plugins/plugin-doctor/hooks/handlers/session-start.py +++ b/plugins/plugin-doctor/hooks/handlers/session-start.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """ SessionStart Handler - Warns when installed agentic-primitives plugins -are outdated. +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). @@ -14,11 +15,15 @@ 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(): @@ -62,6 +67,10 @@ def _state_path() -> Path: 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 @@ -75,6 +84,26 @@ def _refresh_marketplace() -> None: 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(): @@ -83,18 +112,46 @@ def main() -> None: state_path = _state_path() state = freshness.read_state(state_path) now = datetime.now(timezone.utc) - - if freshness.is_check_due( + due = freshness.is_check_due( state.get("last_checked_at"), now, CHECK_INTERVAL_DAYS - ): + ) + + if due: _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: + 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( @@ -102,7 +159,9 @@ def main() -> None: { "hookSpecificOutput": { "hookEventName": "SessionStart", - "additionalContext": freshness.format_context(outdated), + "additionalContext": freshness.format_context( + verified_outdated + ), } } ) diff --git a/tests/unit/claude/hooks/test_plugin_doctor.py b/tests/unit/claude/hooks/test_plugin_doctor.py index 2375c54b..afb4bd65 100644 --- a/tests/unit/claude/hooks/test_plugin_doctor.py +++ b/tests/unit/claude/hooks/test_plugin_doctor.py @@ -14,8 +14,11 @@ 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" @@ -57,6 +60,48 @@ def run_session_start(env_overrides: dict) -> dict: 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 # ============================================================================ @@ -373,7 +418,43 @@ def test_no_context_when_all_plugins_up_to_date(self, tmp_path): ) assert result == {} - def test_emits_context_when_plugin_outdated(self, tmp_path): + 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" @@ -381,19 +462,189 @@ def test_emits_context_when_plugin_outdated(self, tmp_path): 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()}) + 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"] + 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" From 6f34df6ff1dfb2ad87d44388832d7d373e703ef0 Mon Sep 17 00:00:00 2001 From: NeuralEmpowerment <129192050+NeuralEmpowerment@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:36:10 -0700 Subject: [PATCH 17/17] fix(plugin-doctor): URL-encode the plugin/version ref in GitHub API calls Final review flagged that the plugin/version values (already validated as semver by parse_semver, sourced from the local marketplace cache) were interpolated into the GitHub API query string unencoded. Low risk since parse_semver's regex isn't end-anchored and any failure here is already fail-safe, but urllib.parse.quote closes the gap cleanly. Verified against the real GitHub API that the percent-encoded ref still resolves correctly. --- plugins/plugin-doctor/hooks/handlers/session-start.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/plugin-doctor/hooks/handlers/session-start.py b/plugins/plugin-doctor/hooks/handlers/session-start.py index 1f3c4fbd..083f1855 100755 --- a/plugins/plugin-doctor/hooks/handlers/session-start.py +++ b/plugins/plugin-doctor/hooks/handlers/session-start.py @@ -15,6 +15,7 @@ import os import subprocess import sys +import urllib.parse import urllib.request from datetime import datetime, timezone from pathlib import Path @@ -89,9 +90,10 @@ def _fetch_release_commit_date(plugin: str, version: str) -> str | None: 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={plugin}/v{version}&per_page=1" + f"?sha={sha}&per_page=1" ) try: req = urllib.request.Request(