feat(plugin): a directory says which project it belongs to, git repo or not (#4085)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 47s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Failing after 1m1s
CI & Build / Build & push image (push) Skipped

All six hooks scoped their requests one way: `git remote get-url origin`,
resolved server-side through the repo bindings. That key does not exist
outside a git repo, so a session in a plain directory was unscoped in every
hook at once — no project context, no prior-art scoping, no project rules —
and silently, because a missing remote is indistinguishable from a remote
nobody bound.

A `.scribe` file is the second key, read by the shared scribe_scope_query so a
directory scopes the same way everywhere:

    {"instance": "https://scribe.example.com", "project_id": 2, "project": "…"}

`instance` is why the file is not just a number: an id is a different project
on every Scribe, so a marker that travels — a copied directory, a shared
machine, a repo someone else clones — would otherwise scope the session to the
wrong project without a word. Compared host-only, and a mismatch drops the id:
no project beats the wrong project. A bare integer is accepted too, since it
is what a person writes by hand. The marker beats a git remote — someone put
the file there on purpose — which is also how a directory overrides its
binding.

Two things it found on the way:

  * An explicit project_id that did not resolve rendered NO message at all —
    the branch hung off `if project_id` as an `elif`, so a caller holding a
    pointer it believed in got a context that silently omitted the project it
    had asked for. Now reported.
  * The refusal reason was a global set inside a function every caller reads
    through `$( )`. The assignment died with the subshell, leaving the caller
    to read an unset variable under `set -u` — which aborts the hook and costs
    the whole session's SessionStart context, to fetch a warning about a file.
    It comes back through stdout with the id instead, and a test pins it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-16 08:32:29 -04:00
co-authored by Claude Opus 5
parent 47388eda36
commit aa94c73d9e
13 changed files with 401 additions and 52 deletions
+1 -1
View File
@@ -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.16.1211", "version": "2026.09.16.1232",
"author": { "author": {
"name": "Bryan Van Deusen" "name": "Bryan Van Deusen"
}, },
+2 -5
View File
@@ -105,12 +105,9 @@ done <<< "$current"
[ -n "$changed" ] || exit 0 [ -n "$changed" ] || exit 0
scribe_config || : # sets url/token; the call below is guarded on them scribe_config || : # sets url/token; the call below is guarded on them
repo=$(git -C "$repo_root" remote get-url origin 2>/dev/null || true) scope=$(scribe_scope_query "$repo_root")
repo_q="" repo_q=""
if [ -n "$repo" ]; then [ -n "$scope" ] && repo_q="&${scope}"
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
[ -n "$enc" ] && repo_q="&repo=${enc}"
fi
# The dedup channels are the PRE-write hook's files, on purpose (see header). # The dedup channels are the PRE-write hook's files, on purpose (see header).
state_dir="${TMPDIR:-/tmp}/scribe-priorart" state_dir="${TMPDIR:-/tmp}/scribe-priorart"
+3 -6
View File
@@ -65,14 +65,11 @@ q=$(printf '%s' "$prompt" | head -c 2000)
# worth retrieving against were exactly the ones silently dropped. -s slurps. # worth retrieving against were exactly the ones silently dropped. -s slurps.
q_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || exit 0 q_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || exit 0
# Resolve the working repo's remote so the server can scope to the bound project. # Scope to this directory's project — a `.scribe` marker, else the git remote.
repo_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}} repo_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true) scope=$(scribe_scope_query "$repo_dir")
repo_q="" repo_q=""
if [ -n "$repo" ]; then [ -n "$scope" ] && repo_q="&${scope}"
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
[ -n "$enc" ] && repo_q="&repo=${enc}"
fi
# Per-session dedup: ids already injected this session are skipped. # Per-session dedup: ids already injected this session are skipped.
state_dir="${TMPDIR:-/tmp}/scribe-autoinject" state_dir="${TMPDIR:-/tmp}/scribe-autoinject"
+126
View File
@@ -19,6 +19,11 @@
# scribe_rules_live FILE live rule ids from the exclusion ledger, # scribe_rules_live FILE live rule ids from the exclusion ledger,
# comma-joined; entries age out (#3751) # comma-joined; entries age out (#3751)
# scribe_rules_append FILE stdin ids -> the ledger, timestamped # scribe_rules_append FILE stdin ids -> the ledger, timestamped
# scribe_scope_query DIR `project_id=N` or `repo=<enc>` for DIR — the
# project-scope key EVERY hook sends (#4085).
# Helpers: scribe_marker_file, scribe_url_host,
# scribe_marker_read (id<TAB>why-not),
# scribe_marker_project (the id alone)
# #
# Sourced, not executed: `. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"`. # Sourced, not executed: `. "$(dirname "${BASH_SOURCE[0]}")/scribe_defs.sh"`.
@@ -283,3 +288,124 @@ scribe_rules_append() {
now=$(date +%s 2>/dev/null) || now=0 now=$(date +%s 2>/dev/null) || now=0
awk -v ts="$now" 'NF { print $1 "\t" ts }' >> "$f" 2>/dev/null || true awk -v ts="$now" 'NF { print $1 "\t" ts }' >> "$f" 2>/dev/null || true
} }
# ---------------------------------------------------------------------------
# WHICH PROJECT IS THIS DIRECTORY'S? (#4085)
#
# Every hook here scopes its request to a project, and until this existed all
# six did it the same single way: `git remote get-url origin`, resolved
# server-side through the repo bindings. That works well inside a bound repo
# and not at all outside one — a session in a plain directory got no project
# scope from ANY hook, silently, because the one key the whole chain turns on
# only exists in a git repo.
#
# The second key is a `.scribe` file in the directory (or above it, the way
# git finds its root), naming the project the work belongs to. It is a
# POINTER, not a copy: an id and enough to check the id means what it says.
# Nothing Scribe should be holding goes in it.
#
# {"instance": "https://scribe.example.com", "project_id": 2,
# "project": "FabledScribe"}
#
# instance WHICH Scribe the id belongs to, and the reason this file is
# not just a number. A project id means nothing on its own: id 2
# is a different project on every instance, so a marker that
# travels — a copied directory, a shared machine, a repo someone
# else clones — would silently scope a session to the wrong
# project. Compared HOST-ONLY against the configured endpoint, so
# http/https and a trailing slash don't cause a false mismatch.
# A mismatch drops the id: no project beats the wrong project.
# project_id the pointer itself. Required.
# project a human label. NOTHING READS IT. It is there so the file
# answers "what is this?" when opened, and so a stale one is
# visible rather than inert.
#
# A bare integer is also accepted (`echo 2 > .scribe`), because it is what a
# person writes by hand and it parses as JSON already. It skips the instance
# check by having nothing to check — deliberate, and the reason the written
# form carries `instance`.
#
# THE MARKER WINS over a git remote. Someone put the file there on purpose;
# a remote is just where the code happens to be pushed. That also makes the
# marker the way to override a binding for one directory.
# Nearest `.scribe` at or above DIR. Walks up to the filesystem root, capped so
# a pathological path cannot spin.
scribe_marker_file() {
local dir="$1" depth=0
[ -n "$dir" ] || return 0
while [ "$depth" -lt 40 ]; do
[ -f "$dir/.scribe" ] && { printf '%s' "$dir/.scribe"; return 0; }
case "$dir" in ""|"/") return 0 ;; esac
dir=$(dirname -- "$dir" 2>/dev/null) || return 0
depth=$((depth + 1))
done
return 0
}
# Host of a URL, lowercased, port kept. "" for empty input.
scribe_url_host() {
printf '%s' "${1:-}" \
| sed -e 's#^[A-Za-z][A-Za-z0-9+.-]*://##' -e 's#^[^/@]*@##' -e 's#[/?].*$##' \
| tr 'A-Z' 'a-z'
}
# Read a marker file: prints "ID<TAB>REASON", at most one of them non-empty.
#
# "7\t" use project 7
# "\tnames no …" a file is there and deliberately NOT used; say why
# "\t" no marker file at all — the ordinary case, say nothing
#
# One line rather than an id plus a global, because every caller reads this
# through `$( )` and a global set inside a command substitution dies with the
# subshell. The caller would then read an unset variable, which under the
# `set -u` these hooks all run with aborts the hook and costs the whole
# session's context — a failure far larger than the message it was fetching.
scribe_marker_read() {
local f="$1" id inst want
[ -n "$f" ] && [ -f "$f" ] || { printf '\t'; return 0; }
command -v jq >/dev/null 2>&1 || { printf '\t'; return 0; }
# A bare integer is valid JSON, so one filter reads both forms.
id=$(jq -r 'if type=="number" then (.|floor|tostring)
elif type=="object" then (.project_id // empty | tostring)
else empty end' "$f" 2>/dev/null) || id=""
case "$id" in ''|*[!0-9]*) id="" ;; esac
if [ -z "$id" ] || [ "$id" = "0" ]; then
printf '\tnames no project_id'
return 0
fi
inst=$(jq -r 'if type=="object" then (.instance // empty) else empty end' "$f" 2>/dev/null) || inst=""
if [ -n "$inst" ]; then
want=$(scribe_url_host "${url:-}")
inst=$(scribe_url_host "$inst")
if [ -n "$want" ] && [ "$inst" != "$want" ]; then
printf '\tpoints at %s, but this session is configured for %s' "$inst" "$want"
return 0
fi
fi
printf '%s\t' "$id"
}
# Just the id a marker names, or "" — the half scribe_scope_query needs.
scribe_marker_project() {
scribe_marker_read "$1" | cut -f1
}
# The query args identifying DIR's project — `project_id=N` or `repo=<enc>` —
# with NO leading `?` or `&`, so each caller keeps its own separator. Empty
# when neither key is available. Requires `url` to be set (scribe_config) for
# the marker's instance check; without it a marker is still honoured, since an
# unconfigured hook is not going to send the request anyway.
scribe_scope_query() {
local dir="$1" id repo enc
id=$(scribe_marker_project "$(scribe_marker_file "$dir")")
if [ -n "$id" ]; then
printf 'project_id=%s' "$id"
return 0
fi
repo=$(git -C "$dir" remote get-url origin 2>/dev/null || true)
[ -n "$repo" ] || return 0
command -v jq >/dev/null 2>&1 || return 0
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
[ -n "$enc" ] && printf 'repo=%s' "$enc"
}
+3 -6
View File
@@ -150,13 +150,10 @@ q=$(printf '%s' "$code" | head -c 1200)
path_enc=$(printf '%s' "$rel_path" | jq -sRr '@uri' 2>/dev/null) || exit 0 path_enc=$(printf '%s' "$rel_path" | jq -sRr '@uri' 2>/dev/null) || exit 0
code_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || code_enc="" code_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || code_enc=""
# Resolve the working repo's remote so the server can scope to the bound project. # Scope to this directory's project — a `.scribe` marker, else the git remote.
repo=$(git -C "$lookup_dir" remote get-url origin 2>/dev/null || true) scope=$(scribe_scope_query "$lookup_dir")
repo_q="" repo_q=""
if [ -n "$repo" ]; then [ -n "$scope" ] && repo_q="&${scope}"
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
[ -n "$enc" ] && repo_q="&repo=${enc}"
fi
# Per-session dedup, in its own file rather than sharing auto-inject's. Each # Per-session dedup, in its own file rather than sharing auto-inject's. Each
# surface shows a given snippet at most once per session, but they don't silence # surface shows a given snippet at most once per session, but they don't silence
+2 -5
View File
@@ -149,11 +149,8 @@ report() {
enc=$(printf '%s' "$m" | jq -sRr '@uri' 2>/dev/null) || enc="" enc=$(printf '%s' "$m" | jq -sRr '@uri' 2>/dev/null) || enc=""
q="${q}&missing=${enc}" q="${q}&missing=${enc}"
fi fi
repo=$(git -C "${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}" remote get-url origin 2>/dev/null || true) scope=$(scribe_scope_query "${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}")
if [ -n "$repo" ]; then [ -n "$scope" ] && q="${q}&${scope}"
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
[ -n "$enc" ] && q="${q}&repo=${enc}"
fi
curl -fsS --max-time 4 \ curl -fsS --max-time 4 \
-H "Authorization: Bearer ${token}" \ -H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/report-check?${q}" 2>/dev/null "${url%/}/api/plugin/report-check?${q}" 2>/dev/null
+33 -5
View File
@@ -151,14 +151,19 @@ scribe_config || :
dyn="" dyn=""
status="" status=""
if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
# Resolve the working repo's remote so the server can map it to a project. # Which project is this directory's? A `.scribe` marker first, then the git
# remote (#4085). scribe_scope_query answers that, and is shared with the
# other five hooks so a directory scopes the same way everywhere; the pieces
# are re-read here only to explain what happened when nothing resolved.
repo_dir=${CLAUDE_PROJECT_DIR:-$PWD} repo_dir=${CLAUDE_PROJECT_DIR:-$PWD}
marker=$(scribe_marker_file "$repo_dir")
marker_read=$(scribe_marker_read "$marker")
marker_id=${marker_read%%$'\t'*}
marker_why=${marker_read#*$'\t'}
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true) repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
scope=$(scribe_scope_query "$repo_dir")
q="" q=""
if [ -n "$repo" ]; then [ -n "$scope" ] && q="?${scope}"
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
[ -n "$enc" ] && q="?repo=${enc}"
fi
body=$(curl -fsS --max-time 8 \ body=$(curl -fsS --max-time 8 \
-H "Authorization: Bearer ${token}" \ -H "Authorization: Bearer ${token}" \
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body="" "${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
@@ -184,6 +189,29 @@ fi
[ -n "$dyn" ] && append "$dyn" [ -n "$dyn" ] && append "$dyn"
[ -n "$status" ] && append "$status" [ -n "$status" ] && append "$status"
# --- Nothing resolved: say WHICH nothing, and what would fix it (#4085) ---
#
# The server can say "no project is bound to this working directory", and
# until now that was the whole answer. It is the same sentence for a directory
# that is not a repo, a repo whose remote nobody bound, and a marker file
# naming a project this account cannot read — three different problems with
# three different fixes, and no way to tell them apart from inside the session.
#
# The marker is the adapter's convention, so the adapter explains it: the
# server reports whether a project resolved, and this hook — which knows what
# it sent and why — turns that into the sentence the operator can act on. The
# repo case is left to the server's existing "bind this repo" hint.
if [ -n "$dyn" ] && [ -z "$(printf '%s' "$body" | jq -r '.project.id // empty' 2>/dev/null)" ]; then
host=$(scribe_url_host "$url")
if [ -n "$marker_why" ]; then
append "> ⚠️ Scribe: the marker file \`${marker}\` ${marker_why}, so no project context was loaded. Fix the file, or ignore it and bind this directory another way."
elif [ -n "$marker_id" ]; then
append "> ⚠️ Scribe: \`${marker}\` names project ${marker_id}, which this account cannot read on ${host} — it may belong to a different Scribe instance, or the project may have been deleted. Check with \`list_projects()\` and correct the file."
elif [ -z "$repo" ]; then
append "> ️ Scribe: this directory is not a git repository and has no \`.scribe\` marker, so no project context was loaded — every hook this session is unscoped. If this work belongs to a Scribe project, call \`list_projects()\` and write the marker: \`{\"instance\": \"${url%/}\", \"project_id\": <id>, \"project\": \"<title>\"}\` in \`${repo_dir}/.scribe\`. Future sessions here load that project on their own."
fi
fi
# Compaction re-grounding: lead with a reload banner when this fire is a compact. # Compaction re-grounding: lead with a reload banner when this fire is a compact.
if [ "$source" = "compact" ]; then if [ "$source" = "compact" ]; then
prepend "> ⟳ This session was just COMPACTED — earlier turns are now a summary, so in-flight detail may be lost. Any rules that had been retrieved went into that summary with everything else, so treat yourself as holding none: before the next consequential act, ask again with \`search(content_type=\"rule\")\` rather than trusting a half-remembered one. Re-run \`enter_project()\` for the active project, check its recent milestones and open tasks, and reconcile what you are mid-way through against what Scribe records. Scribe is the record." prepend "> ⟳ This session was just COMPACTED — earlier turns are now a summary, so in-flight detail may be lost. Any rules that had been retrieved went into that summary with everything else, so treat yourself as holding none: before the next consequential act, ask again with \`search(content_type=\"rule\")\` rather than trusting a half-remembered one. Re-run \`enter_project()\` for the active project, check its recent milestones and open tasks, and reconcile what you are mid-way through against what Scribe records. Scribe is the record."
+2 -5
View File
@@ -63,11 +63,8 @@ tool_enc=$(printf '%s' "$tool_name" | jq -sRr '@uri' 2>/dev/null) || exit 0
repo_q="" repo_q=""
lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}} lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
repo_remote=$(git -C "$lookup_dir" remote get-url origin 2>/dev/null || true) scope=$(scribe_scope_query "$lookup_dir")
if [ -n "$repo_remote" ]; then [ -n "$scope" ] && repo_q="&${scope}"
repo_enc=$(printf '%s' "$repo_remote" | jq -sRr '@uri' 2>/dev/null) || repo_enc=""
[ -n "$repo_enc" ] && repo_q="&repo=${repo_enc}"
fi
# THE SHARED SESSION LEDGER, and the thing most worth getting right here. # THE SHARED SESSION LEDGER, and the thing most worth getting right here.
# #
+17 -4
View File
@@ -13,10 +13,23 @@ asked for.
## Do this first (every session) ## Do this first (every session)
If the working repo maps to a Scribe project (you're in a known repo, or If the working directory maps to a Scribe project, call `enter_project(id)`
`list_repo_bindings` shows a binding), call `enter_project(id)` — it returns the it returns the project's goal, the milestones and open tasks worked on most
project's goal, the milestones and open tasks worked on most recently, its recently, its Systems and the titles of its own rules in one shot.
Systems and the titles of its own rules in one shot.
**A directory does not have to be a git repo to have a project.** A repo is
bound by its remote (`list_repo_bindings` shows the bindings). Anything else —
a notes folder, a server's config directory, a scratch directory — is bound by
a `.scribe` file naming the project:
{"instance": "https://scribe.example.com", "project_id": 2, "project": "Homelab"}
`instance` is what makes the id trustworthy. An id means nothing on its own —
it is a different project on every Scribe — so a marker that has travelled to
another instance is ignored rather than followed to the wrong project. A bare
`2` also works when writing the file by hand. When work plainly belongs to a
project and the directory names none, offer to write the marker;
`list_projects` has the id.
Then **ask before you act**: before anything hard to reverse or outward-facing, Then **ask before you act**: before anything hard to reverse or outward-facing,
search the rules for what you are about to do. Reflex 2 below is why asking, search the rules for what you are about to do. Reflex 2 below is why asking,
+5 -2
View File
@@ -64,8 +64,11 @@ async def session_context():
resolves it to the bound project (see services/repo_bindings); an resolves it to the bound project (see services/repo_bindings); an
unbound repo yields a "bind this repo" hint instead. This is how the unbound repo yields a "bind this repo" hint instead. This is how the
active project is determined — the plugin never pins a project id. active project is determined — the plugin never pins a project id.
project_id (optional int) — explicit override, mainly for manual/ad-hoc project_id (optional int) — the project named directly, and the only
curl testing; takes precedence over `repo` when set. key a caller outside a git repo has (#4085): the plugin's hooks
send it when a `.scribe` marker file names a project. Takes
precedence over `repo`. Access-checked like any other read — an id
this account cannot read loads no project rather than failing.
""" """
project_id, _repo, unbound_repo = await _project_scope() project_id, _repo, unbound_repo = await _project_scope()
result = await plugin_ctx_svc.build_session_context( result = await plugin_ctx_svc.build_session_context(
+32 -13
View File
@@ -2171,7 +2171,10 @@ async def build_session_context(
Args: Args:
user_id: the operator. user_id: the operator.
project_id: the resolved active project (0 = none). The endpoint project_id: the resolved active project (0 = none). The endpoint
resolves this from the working repo's remote, not from config. resolves this from the working repo's remote, or from a `.scribe`
marker file naming the project directly — the only key a session
outside a git repo has (#4085). A non-zero id that does not
resolve is reported rather than silently dropped.
unbound_repo: when the hook sent a repo remote that maps to no project, unbound_repo: when the hook sent a repo remote that maps to no project,
its normalized key — triggers a one-line "bind this repo" hint so its normalized key — triggers a one-line "bind this repo" hint so
the binding is self-healing. the binding is self-healing.
@@ -2241,18 +2244,34 @@ async def build_session_context(
f"`get_design_system({design['id']})` → " f"`get_design_system({design['id']})` → "
f"`resolved_guidance`.", f"`resolved_guidance`.",
] ]
elif unbound_repo: # Nothing loaded — say which nothing (#4085). This used to hang off the
lines += [ # `if project_id:` above as an `elif`, which meant an id that was SENT and
"", # did not resolve produced no message at all: the outer branch was taken,
"## Repository not yet bound", # the inner one was not, and the caller got a context that simply omitted
f"This repo (`{unbound_repo}`) isn't mapped to a Scribe project, so " # the project it had asked for. That is the one case worth being loudest
"no project context was loaded. Bind it once with " # about, because the caller is holding a pointer it believes in.
f'`bind_repo(repo_url="{unbound_repo}", project_id=<id>)` ' if project_dict is None:
"(call `list_projects` to find the id) and future sessions here will " if project_id:
"auto-load that project's context.", lines += [
] "",
else: f"## Project {project_id} could not be loaded",
lines += ["", "No Scribe project is bound to this working directory."] f"This session asked for project {project_id}, but this account "
"cannot read it — the id may belong to a different Scribe "
"instance, or the project may have been deleted. "
"`list_projects` shows what is readable here.",
]
elif unbound_repo:
lines += [
"",
"## Repository not yet bound",
f"This repo (`{unbound_repo}`) isn't mapped to a Scribe project, so "
"no project context was loaded. Bind it once with "
f'`bind_repo(repo_url="{unbound_repo}", project_id=<id>)` '
"(call `list_projects` to find the id) and future sessions here will "
"auto-load that project's context.",
]
else:
lines += ["", "No Scribe project is bound to this working directory."]
context = "\n".join(line for line in lines if line is not None) context = "\n".join(line for line in lines if line is not None)
if len(context) > _MAX_CHARS: if len(context) > _MAX_CHARS:
+150
View File
@@ -0,0 +1,150 @@
"""`.scribe` — the second key a directory can be scoped by (#4085).
Every hook in `plugin/hooks/` scopes its request to a project, and until this
existed all six turned on one key: `git remote get-url origin`. That key does
not exist outside a git repo, so a session in a plain directory was unscoped
everywhere at once — silently, because a missing remote is indistinguishable
from a remote nobody bound.
`scribe_scope_query` is the shared answer, so these run the real shell rather
than a reimplementation of it. What they pin:
* the marker is found from a subdirectory, the way git finds its root;
* a bare integer works, because that is what a person writes by hand;
* the marker BEATS a git remote, since someone put the file there on purpose;
* an `instance` naming a different Scribe is refused — the case the field
exists for. A project id means nothing on its own: id 2 is a different
project on every instance, so a marker that travels would otherwise scope
the session to the wrong project, confidently and without a word.
That last one is the reason the file is JSON and not a number, so it is tested
from both directions: the matching host is honoured, the mismatched one is not.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
DEFS = ROOT / "plugin" / "hooks" / "scribe_defs.sh"
INSTANCE = "https://scribe.example.com"
def _sh(script: str, cwd: Path, env_extra: dict | None = None) -> str:
for tool in ("bash", "jq"):
if shutil.which(tool) is None:
pytest.skip(f"hook runtime tool {tool!r} not installed")
env = {"PATH": os.environ["PATH"], "HOME": str(cwd)}
env.update(env_extra or {})
body = f'set -uo pipefail\n. "{DEFS}"\nurl="{INSTANCE}"\n{script}\n'
out = subprocess.run(["bash", "-c", body], cwd=cwd, capture_output=True,
text=True, env=env, timeout=30)
assert out.returncode == 0, out.stderr
return out.stdout.strip()
def _marker(d: Path, **fields) -> None:
(d / ".scribe").write_text(json.dumps(fields))
def _git_repo(d: Path, remote: str) -> None:
env = {"PATH": os.environ["PATH"], "HOME": str(d)}
subprocess.run(["git", "init", "-q"], cwd=d, check=True, env=env)
subprocess.run(["git", "remote", "add", "origin", remote], cwd=d, check=True, env=env)
def test_a_bare_integer_is_a_valid_marker(tmp_path):
"""What someone writes by hand. It parses as JSON already, so one filter
reads it and the written form alike."""
(tmp_path / ".scribe").write_text("7\n")
assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) == "project_id=7"
def test_the_marker_is_found_from_a_subdirectory(tmp_path):
_marker(tmp_path, instance=INSTANCE, project_id=2, project="FabledScribe")
deep = tmp_path / "src" / "scribe" / "services"
deep.mkdir(parents=True)
assert _sh(f'scribe_scope_query "{deep}"', tmp_path) == "project_id=2"
def test_a_matching_instance_is_honoured(tmp_path):
"""Host-only comparison, so http/https and a trailing slash are not a
mismatch — only a genuinely different Scribe is."""
_marker(tmp_path, instance="http://scribe.example.com/", project_id=2)
assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) == "project_id=2"
def test_a_marker_for_another_instance_is_refused_with_a_reason(tmp_path):
"""The case the `instance` field exists for. No project beats the wrong
project, and the reason is carried so the session can say which it was."""
_marker(tmp_path, instance="https://someone-elses.example.org", project_id=2)
assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) == ""
said = _sh(f'scribe_marker_read "{tmp_path}/.scribe"', tmp_path)
assert "someone-elses.example.org" in said and "scribe.example.com" in said
def test_the_marker_beats_a_git_remote(tmp_path):
"""Someone put the file there deliberately; a remote is only where the
code happens to be pushed. This is also how a directory overrides its
binding."""
_git_repo(tmp_path, "git@git.example.com:someone/thing.git")
assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path).startswith("repo=")
_marker(tmp_path, instance=INSTANCE, project_id=2)
assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) == "project_id=2"
def test_without_a_marker_the_git_remote_still_answers(tmp_path):
"""The path every existing install is on — it must not have moved."""
_git_repo(tmp_path, "git@git.example.com:someone/thing.git")
got = _sh(f'scribe_scope_query "{tmp_path}"', tmp_path)
assert got.startswith("repo=") and "git.example.com" in got.replace("%2F", "/")
def test_a_plain_directory_with_no_marker_scopes_to_nothing(tmp_path):
"""Not an error — the caller sends no scope and the server says so."""
assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) == ""
@pytest.mark.parametrize("body", ["", "not json at all", "{}", '{"project_id": 0}',
'{"project_id": "../../etc"}', "[1,2,3]"])
def test_an_unusable_marker_is_refused_rather_than_sent(tmp_path, body):
"""A marker is operator-written and can say anything. Nothing that is not
a positive integer may reach the query string."""
(tmp_path / ".scribe").write_text(body)
assert _sh(f'scribe_scope_query "{tmp_path}"', tmp_path) == ""
def test_the_reason_survives_the_command_substitution_that_fetches_it(tmp_path):
"""Regression on a bug this nearly shipped with.
The reason used to be a global the function set, which every caller reads
through `$( )` — a subshell, so the assignment died with it and the caller
saw an UNSET variable. The hooks all run `set -u`, where reading one aborts
the script: a directory with a misaddressed marker would have cost the
session its entire SessionStart context, to fetch a warning about a file.
So the reason comes back through stdout with the id, and this asserts on
the shape the caller actually uses: read in a subshell, split, and used
under `set -u` without the shell dying.
"""
_marker(tmp_path, instance="https://elsewhere.example.org", project_id=2)
got = _sh(
f'read_out=$(scribe_marker_read "$(scribe_marker_file "{tmp_path}")")\n'
'printf "id=[%s] why=[%s]" "${read_out%%$\'\\t\'*}" "${read_out#*$\'\\t\'}"',
tmp_path,
)
assert got.startswith("id=[]")
assert "elsewhere.example.org" in got
def test_the_walk_up_terminates_at_the_root(tmp_path):
"""No marker anywhere above: the loop must end rather than spin. tmp_path
is several levels down from /, so this really does walk."""
deep = tmp_path / "a" / "b" / "c"
deep.mkdir(parents=True)
assert _sh(f'scribe_scope_query "{deep}"', tmp_path) == ""
+25
View File
@@ -215,6 +215,31 @@ async def test_build_session_context_unbound_repo_emits_bind_hint():
assert "## Active project" not in ctx assert "## Active project" not in ctx
@pytest.mark.asyncio
async def test_a_project_id_that_does_not_resolve_is_reported_not_dropped():
"""A `.scribe` marker names a project directly (#4085), so for the first
time a caller arrives holding a pointer it BELIEVES in. If the id is for
another instance, or names a deleted project, the session has to be told —
it cannot infer it from an absence.
This used to render nothing whatsoever: the branch hung off `if project_id`
as an `elif`, so an id that was sent and failed took the outer arm, found
no project, and fell out of the block having said nothing at all.
"""
from scribe.services.plugin_context import build_session_context
with patch("scribe.services.plugin_context.projects_svc.get_project",
AsyncMock(return_value=None)):
out = await build_session_context(user_id=7, project_id=41)
ctx = out["context"]
assert out["project"] is None
assert "## Project 41 could not be loaded" in ctx
assert "list_projects" in ctx
# Not mistaken for the repo case, which has a different remedy.
assert "## Repository not yet bound" not in ctx
assert "No Scribe project is bound" not in ctx
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_build_process_manifest_renders_stub_specs(): async def test_build_process_manifest_renders_stub_specs():
items = [ items = [