From 841506b10c60d636dd9c8cbb45f345c2c2ce8fdc Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 3 Aug 2026 11:57:33 -0400 Subject: [PATCH] feat(plugin): every session says which plugin version is executing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twice a shipped fix failed to reach a live install, and both times the only detector was the operator saying "I don't think it updated" (#2198, #2209). The reason it is hard to see: an install has two halves and only one self-updates. The marketplace clone pulls on its own; the CACHE is what executes and refreshes only when the manifest version changes. So inspecting the clone shows the fix present while the broken copy keeps running — the obvious debugging move actively misleads. The SessionStart context now names the version it is running. That makes "what is actually executing?" answerable from the transcript rather than by archaeology in the cache directory. DELIBERATELY SMALLER THAN THE ISSUE PROPOSED. #2220 recommended reporting the version to the server, storing last-seen per user, and surfacing it in Settings. That is three surfaces and a migration to answer a question the session can answer about itself. Per the operator's framing on #2338 — "I'm afraid of building another integration between two more surfaces, you being able to notice is enough" — the visibility is the deliverable, not the plumbing. It also lands the issue's own caveat, which the server-side design could not: the state most needing diagnosis is the one where credentials never arrive, and there the dynamic tier does not run at all. This marker is keyless and networkless, so it still appears — verified against both the unconfigured and unreachable-instance paths. CI pins it, asserting WITHOUT credentials for the same reason. Manifest 0.1.22 -> 0.1.23, because a shipped hook changed and an unbumped manifest is precisely the failure this commit is about. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs --- plugin/.claude-plugin/plugin.json | 2 +- plugin/hooks/scribe_session_context.sh | 25 +++++++++++++++++ scripts/check_plugin.py | 39 ++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index b0fd4a3..98685c7 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", "description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.", - "version": "0.1.22", + "version": "0.1.23", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/hooks/scribe_session_context.sh b/plugin/hooks/scribe_session_context.sh index 4c6b52c..77b5a17 100755 --- a/plugin/hooks/scribe_session_context.sh +++ b/plugin/hooks/scribe_session_context.sh @@ -61,6 +61,31 @@ prepend() { if [ -n "$out" ]; then out="$1"$'\n\n---\n\n'"${out}"; else out="$1" # --- Tier 1: static behavioral mandate (always, keyless, networkless) --- [ -f "$here/scribe_static_context.md" ] && out=$(cat "$here/scribe_static_context.md") +# --- Which version is actually RUNNING (keyless, networkless) --- +# +# An install has two halves and only one self-updates: +# +# marketplaces/…/scribe-plugin/ git clone — pulls on its own +# cache/…/scribe// what EXECUTES — refreshed only when the +# manifest version changes +# +# So inspecting the clone shows a fix present while the broken copy keeps +# running, and the obvious debugging move actively misleads (#2209). Twice, the +# only detector was the operator saying "I don't think it updated". +# +# Naming the running version in every session makes that answerable from the +# transcript instead of by archaeology in the cache directory. Deliberately NOT +# a server round-trip or a stored per-user record: the state most needing +# diagnosis is the one where credentials never arrive, and this line still +# appears there. +manifest="$here/../.claude-plugin/plugin.json" +if [ -f "$manifest" ]; then + plugin_version=$(jq -r '.version // empty' "$manifest" 2>/dev/null) || plugin_version="" + if [ -n "$plugin_version" ]; then + append "> Scribe plugin **v${plugin_version}** is executing in this session. A fix merged after this version has not reached it — the marketplace clone updates on its own, but the cache that runs only refreshes when the manifest version changes." + fi +fi + # --- Tier 2: dynamic rules + active-project context (best-effort) --- url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}} token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}} diff --git a/scripts/check_plugin.py b/scripts/check_plugin.py index 22c706b..1fb44ca 100755 --- a/scripts/check_plugin.py +++ b/scripts/check_plugin.py @@ -307,6 +307,44 @@ def check_local_prior_art_needs_no_instance() -> None: ok("prior-art local arm: answers with no instance configured") +def check_session_context_reports_its_version() -> None: + """The SessionStart context must name the plugin version it is running. + + An install has two halves and only one self-updates: the marketplace clone + pulls on its own, while the CACHE is what executes and refreshes only when + the manifest version changes. So a shipped fix can sit unreached while + inspecting the clone shows it present — the obvious debugging move + misleads, and twice the only detector was a human saying "I don't think it + updated" (#2209, #2220). + + Asserted WITHOUT credentials on purpose. The state most needing diagnosis + is the one where the token never arrives, and a marker that vanished there + would be missing exactly when it is wanted. + """ + script = HOOKS_DIR / "scribe_session_context.sh" + if not script.is_file() or not shutil.which("jq"): + skip("version marker: hook or jq missing") + return + + manifest_v = manifest_version() + if manifest_v is None: + fail("version marker: could not read the manifest version") + return + + try: + proc = _run_hook(script, json.dumps({"source": "startup"}), {}) + except subprocess.TimeoutExpired: + fail("version marker: hook hung") + return + if proc.returncode != 0: + fail(f"version marker: hook exited {proc.returncode}") + elif manifest_v not in proc.stdout: + fail(f"version marker: session context never names v{manifest_v} — " + f"a stale install would be undetectable from the transcript") + else: + ok(f"version marker: session context reports v{manifest_v}, no credentials needed") + + def _git(*args: str) -> tuple[int, str]: proc = subprocess.run( ["git", *args], capture_output=True, text=True, cwd=ROOT @@ -399,6 +437,7 @@ def main() -> int: check_shellcheck() check_fail_open() check_local_prior_art_needs_no_instance() + check_session_context_reports_its_version() if not args.no_version: check_version_bump(args.base)