diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 391590b..a1620ce 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", "description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).", - "version": "2026.09.23.2005", + "version": "2026.09.23.2007", "author": { "name": "Bryan Van Deusen" }, diff --git a/plugin/hooks/scribe_session_context.sh b/plugin/hooks/scribe_session_context.sh index c95d721..d269c4d 100755 --- a/plugin/hooks/scribe_session_context.sh +++ b/plugin/hooks/scribe_session_context.sh @@ -189,11 +189,35 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then scope=$(scribe_scope_query "$repo_dir") q="" [ -n "$scope" ] && q="?${scope}" - body=$(curl -fsS --max-time 8 \ - -H "Authorization: Bearer ${token}" \ - "${url%/}/api/plugin/context${q}" 2>/dev/null) || body="" + # ONE FETCH, NAMED WHEN IT FAILS (#4366). This used to be `curl -f … || + # body=""`, which folded a timeout, an HTTP error and a refused key into one + # sentence — so a session that started blind could not say why, and neither + # could the operator afterwards. curl's write-out still prints on a failed + # transfer (as `000`), so the status and the elapsed time come back either way. + fetch_context() { + local resp meta + resp=$(curl -sS --max-time "$1" -w '\n%{http_code} %{time_total}' \ + -H "Authorization: Bearer ${token}" \ + "${url%/}/api/plugin/context${q}" 2>/dev/null) + ctx_rc=$? + meta=${resp##*$'\n'} + body=${resp%$'\n'*} + [ "$body" = "$resp" ] && body="" + ctx_code=${meta%% *} + ctx_took=${meta#* } + } + # Deadlines: 8s is the long-standing first try, sized for a cold instance. + # ONE retry at 6s, and only for failures a second try can change — a + # timeout, a dropped connection, a 5xx. A 4xx is the key or the scope and + # will say the same thing twice. Worst case is ~14s of startup, against a + # whole session run without its project. + fetch_context 8 + case "$ctx_rc:$ctx_code" in + 0:2*|0:4*) ;; + *) fetch_context 6 ;; + esac body_flat="" - if [ -n "$body" ]; then + if [ "$ctx_rc" = 0 ] && [ "${ctx_code#2}" != "$ctx_code" ] && [ -n "$body" ]; then body_flat=$(printf '%s' "$body" | scribe_json_flat) dyn=$(scribe_json_pick "$body_flat" '.context') fi @@ -201,7 +225,28 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then # (milestone 394). Nothing is preloaded, so there is no set whose # drift a later write could be told about — a rule is retrieved at # the moment it applies, which cannot be stale. - [ -z "$dyn" ] && status="> ⚠️ Scribe: live project context could not be loaded this session (instance unreachable or request failed). The using-scribe skill still applies — ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\` as needed." + if [ -z "$dyn" ]; then + if [ "$ctx_rc" = 28 ]; then + why="the instance did not answer in time (8s, then ${ctx_took}s on a retry)" + elif [ "$ctx_rc" != 0 ]; then + why="the instance could not be reached (curl exit ${ctx_rc}, after a retry)" + elif [ "$ctx_code" = 401 ] || [ "$ctx_code" = 403 ]; then + why="the API key was refused (HTTP ${ctx_code})" + elif [ "${ctx_code#2}" = "$ctx_code" ]; then + why="the instance answered HTTP ${ctx_code}" + else + why="the instance answered but sent no context" + fi + # The first move, stated as one. "As needed" read as optional, and a + # session that skips it starts with no recent milestones or open tasks — + # so it cannot know what prior work exists to look for (#4366). + if [ -n "$marker_id" ] && [ -z "$marker_why" ]; then + first="Start by calling \`enter_project(${marker_id})\`" + else + first="Start by finding this repo's project with \`list_projects()\` and calling \`enter_project()\`" + fi + status="> ⚠️ Scribe: live project context was not loaded this session — ${why}. The tools may still answer. ${first} before any other work: it loads the recent milestones and open tasks this session would otherwise begin without, and prior work you cannot see is work you will redo. The using-scribe skill still applies." + fi elif [ -n "$url" ] && [ -z "$token" ]; then status="> ⚠️ Scribe: live context disabled this session — the API key is not configured (Scribe base URL is). Set it with \`/plugin\` → Scribe → configure, or export SCRIBE_TOKEN. Tools still work; ask for rules with \`search(content_type=\"rule\")\` and project context with \`enter_project()\`." elif [ -z "$url" ] && [ -z "$token" ]; then diff --git a/tests/test_session_context_failure.py b/tests/test_session_context_failure.py new file mode 100644 index 0000000..da68727 --- /dev/null +++ b/tests/test_session_context_failure.py @@ -0,0 +1,54 @@ +"""A SessionStart that loads no project says why, and names the first move (#4366). + +The fetch used to be `curl -f … || body=""`: a timeout, an HTTP error and a +refused key all produced one sentence, which ended by suggesting +`enter_project()` "as needed". A session that read it as optional started with +no recent milestones or open tasks, and so had no way to know which prior work +existed to look for. These pin the two halves of the fix on the path that needs +no server: the cause is named, and the fallback is a concrete first step. +""" +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +from tests.helpers import need_tools + +HOOK = Path(__file__).resolve().parents[1] / "plugin" / "hooks" / "scribe_session_context.sh" + + +def run_hook(tmp_path: Path, url: str, marker_project: int | None = None) -> str: + need_tools("bash", "curl", "awk") + if marker_project is not None: + (tmp_path / ".scribe").write_text(json.dumps({ + "instance": url, "project_id": marker_project, "project": "P", + })) + env = {**os.environ, "SCRIBE_URL": url, "SCRIBE_TOKEN": "t", + "CLAUDE_PROJECT_DIR": str(tmp_path)} + r = subprocess.run(["bash", str(HOOK)], input=b'{"source":"startup"}', + capture_output=True, env=env, timeout=60) + assert r.returncode == 0, r.stderr.decode() + return json.loads(r.stdout)["hookSpecificOutput"]["additionalContext"] + + +# Port 9 (discard) on loopback: refused at once, so both tries fail fast. +DEAD = "http://127.0.0.1:9" + + +def test_an_unreachable_instance_is_named_as_one(tmp_path): + out = run_hook(tmp_path, DEAD) + assert "could not be reached (curl exit 7, after a retry)" in out + + +def test_the_fallback_is_a_first_step_not_an_option(tmp_path): + out = run_hook(tmp_path, DEAD) + assert "as needed" not in out + assert "Start by finding this repo's project" in out + assert "before any other work" in out + + +def test_a_marker_names_the_exact_call(tmp_path): + out = run_hook(tmp_path, DEAD, marker_project=31) + assert "Start by calling `enter_project(31)`" in out