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
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:
@@ -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.16.1211",
|
||||
"version": "2026.09.16.1232",
|
||||
"author": {
|
||||
"name": "Bryan Van Deusen"
|
||||
},
|
||||
|
||||
@@ -105,12 +105,9 @@ done <<< "$current"
|
||||
[ -n "$changed" ] || exit 0
|
||||
|
||||
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=""
|
||||
if [ -n "$repo" ]; then
|
||||
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
||||
fi
|
||||
[ -n "$scope" ] && repo_q="&${scope}"
|
||||
|
||||
# The dedup channels are the PRE-write hook's files, on purpose (see header).
|
||||
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
||||
|
||||
@@ -65,14 +65,11 @@ q=$(printf '%s' "$prompt" | head -c 2000)
|
||||
# worth retrieving against were exactly the ones silently dropped. -s slurps.
|
||||
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=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
|
||||
scope=$(scribe_scope_query "$repo_dir")
|
||||
repo_q=""
|
||||
if [ -n "$repo" ]; then
|
||||
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
||||
fi
|
||||
[ -n "$scope" ] && repo_q="&${scope}"
|
||||
|
||||
# Per-session dedup: ids already injected this session are skipped.
|
||||
state_dir="${TMPDIR:-/tmp}/scribe-autoinject"
|
||||
|
||||
@@ -19,6 +19,11 @@
|
||||
# scribe_rules_live FILE live rule ids from the exclusion ledger,
|
||||
# comma-joined; entries age out (#3751)
|
||||
# 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"`.
|
||||
|
||||
@@ -283,3 +288,124 @@ scribe_rules_append() {
|
||||
now=$(date +%s 2>/dev/null) || now=0
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
repo=$(git -C "$lookup_dir" remote get-url origin 2>/dev/null || true)
|
||||
# Scope to this directory's project — a `.scribe` marker, else the git remote.
|
||||
scope=$(scribe_scope_query "$lookup_dir")
|
||||
repo_q=""
|
||||
if [ -n "$repo" ]; then
|
||||
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
||||
fi
|
||||
[ -n "$scope" ] && repo_q="&${scope}"
|
||||
|
||||
# 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
|
||||
|
||||
@@ -149,11 +149,8 @@ report() {
|
||||
enc=$(printf '%s' "$m" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||
q="${q}&missing=${enc}"
|
||||
fi
|
||||
repo=$(git -C "${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}" remote get-url origin 2>/dev/null || true)
|
||||
if [ -n "$repo" ]; then
|
||||
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||
[ -n "$enc" ] && q="${q}&repo=${enc}"
|
||||
fi
|
||||
scope=$(scribe_scope_query "${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}")
|
||||
[ -n "$scope" ] && q="${q}&${scope}"
|
||||
curl -fsS --max-time 4 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${url%/}/api/plugin/report-check?${q}" 2>/dev/null
|
||||
|
||||
@@ -151,14 +151,19 @@ scribe_config || :
|
||||
dyn=""
|
||||
status=""
|
||||
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}
|
||||
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)
|
||||
scope=$(scribe_scope_query "$repo_dir")
|
||||
q=""
|
||||
if [ -n "$repo" ]; then
|
||||
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||
[ -n "$enc" ] && q="?repo=${enc}"
|
||||
fi
|
||||
[ -n "$scope" ] && q="?${scope}"
|
||||
body=$(curl -fsS --max-time 8 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
|
||||
@@ -184,6 +189,29 @@ fi
|
||||
[ -n "$dyn" ] && append "$dyn"
|
||||
[ -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.
|
||||
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."
|
||||
|
||||
@@ -63,11 +63,8 @@ tool_enc=$(printf '%s' "$tool_name" | jq -sRr '@uri' 2>/dev/null) || exit 0
|
||||
|
||||
repo_q=""
|
||||
lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
||||
repo_remote=$(git -C "$lookup_dir" remote get-url origin 2>/dev/null || true)
|
||||
if [ -n "$repo_remote" ]; then
|
||||
repo_enc=$(printf '%s' "$repo_remote" | jq -sRr '@uri' 2>/dev/null) || repo_enc=""
|
||||
[ -n "$repo_enc" ] && repo_q="&repo=${repo_enc}"
|
||||
fi
|
||||
scope=$(scribe_scope_query "$lookup_dir")
|
||||
[ -n "$scope" ] && repo_q="&${scope}"
|
||||
|
||||
# THE SHARED SESSION LEDGER, and the thing most worth getting right here.
|
||||
#
|
||||
|
||||
@@ -13,10 +13,23 @@ asked for.
|
||||
|
||||
## Do this first (every session)
|
||||
|
||||
If the working repo maps to a Scribe project (you're in a known repo, or
|
||||
`list_repo_bindings` shows a binding), call `enter_project(id)` — it returns the
|
||||
project's goal, the milestones and open tasks worked on most recently, its
|
||||
Systems and the titles of its own rules in one shot.
|
||||
If the working directory maps to a Scribe project, call `enter_project(id)` —
|
||||
it returns the project's goal, the milestones and open tasks worked on most
|
||||
recently, its 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,
|
||||
search the rules for what you are about to do. Reflex 2 below is why asking,
|
||||
|
||||
Reference in New Issue
Block a user