dev → main: auto-inject reads the conversation, startup names its failure, menu lines carry metadata, titles are names #183
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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(<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
|
||||
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
|
||||
|
||||
@@ -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