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",
|
"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