fix(plugin): a SessionStart that loads no project says why, and names the first move (#4366)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / Build & push image (push) Canceled after 0s
CI & Build / Python tests (push) Canceled after 23s
CI & Build / integration (push) Canceled after 27s
CI & Build / TypeScript typecheck (push) Canceled after 28s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / Build & push image (push) Canceled after 0s
CI & Build / Python tests (push) Canceled after 23s
CI & Build / integration (push) Canceled after 27s
CI & Build / TypeScript typecheck (push) Canceled after 28s
The context fetch folded a timeout, an HTTP error and a refused key into one sentence that ended "enter_project() as needed" -- read as optional, so a session could start with no recent milestones or open tasks and no way to know what prior work existed. The fetch now names the cause and the elapsed time, retries once (6s) only where a retry can change the answer, and the fallback states enter_project as the first step, with the marker's project id when there is one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"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).",
|
"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": {
|
"author": {
|
||||||
"name": "Bryan Van Deusen"
|
"name": "Bryan Van Deusen"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -189,11 +189,35 @@ if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
|
|||||||
scope=$(scribe_scope_query "$repo_dir")
|
scope=$(scribe_scope_query "$repo_dir")
|
||||||
q=""
|
q=""
|
||||||
[ -n "$scope" ] && q="?${scope}"
|
[ -n "$scope" ] && q="?${scope}"
|
||||||
body=$(curl -fsS --max-time 8 \
|
# ONE FETCH, NAMED WHEN IT FAILS (#4366). This used to be `curl -f … ||
|
||||||
-H "Authorization: Bearer ${token}" \
|
# body=""`, which folded a timeout, an HTTP error and a refused key into one
|
||||||
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
|
# 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=""
|
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)
|
body_flat=$(printf '%s' "$body" | scribe_json_flat)
|
||||||
dyn=$(scribe_json_pick "$body_flat" '.context')
|
dyn=$(scribe_json_pick "$body_flat" '.context')
|
||||||
fi
|
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
|
# (milestone 394). Nothing is preloaded, so there is no set whose
|
||||||
# drift a later write could be told about — a rule is retrieved at
|
# drift a later write could be told about — a rule is retrieved at
|
||||||
# the moment it applies, which cannot be stale.
|
# 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(<id>)\`"
|
||||||
|
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
|
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()\`."
|
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
|
elif [ -z "$url" ] && [ -z "$token" ]; then
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user