From 6a6a388ecd7777a73136b5dfded249066f182023 Mon Sep 17 00:00:00 2001
From: bvandeusen
Date: Fri, 31 Jul 2026 23:42:25 -0400
Subject: [PATCH 01/12] docs: Fabled-Git, not Forgejo, in ci-requirements
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The instance has run Gitea since the migration. Prose only — no workflow or
path change. Scribe issue #2272.
---
ci-requirements.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ci-requirements.md b/ci-requirements.md
index 217dbf4..ee09c7e 100644
--- a/ci-requirements.md
+++ b/ci-requirements.md
@@ -22,7 +22,7 @@ real Postgres), build (docker buildx).
- uv (test + integration jobs run `uv sync --locked`; installed in the
image since the ci-python Dockerfile started pip-installing it)
- docker CLI + buildx (build job pushes the production image to the
- Forgejo registry)
+ Fabled-Git registry)
## Per-job tool installs
From 17d59fa3e03042abdd32cfda40dcafff98012b95 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Fri, 31 Jul 2026 23:43:40 -0400
Subject: [PATCH 02/12] feat(prior-art): ask the repo, not just the record,
before writing a definition
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Scribe has never read a line of the codebase. Every Drafter surface recalls
from the RECORD — things someone deliberately recorded — so a helper nobody
thought to record is invisible to all of them. That is how `.btn-primary` came
to be defined four times, in four scoped stylesheets, already diverged: it was
never a snippet, so no threshold and no query rewrite could ever have surfaced
it (#2280).
The write-path hook already runs on the developer's machine, inside the repo,
holding the code about to be written. It can simply look. No index, no storage,
no staleness story, no server round-trip.
Verified against this repo with Scribe unconfigured:
.btn-primary is already defined in 4 other file(s):
DesignSystemsView.vue ProjectListView.vue SettingsView.vue
SnippetEditorView.vue
Three properties it needs, all checked by hand:
- DEFINITION-shaped patterns only. Grepping bare occurrences would match every
call site and bury the real finding, and a hint that is mostly noise is one
people learn to skip — worse than none. A payload containing only calls to
embed_note() stays silent; one containing `def embed_note` does not.
- The target file is excluded, so editing the file that already defines
something doesn't report it against itself.
- It runs when Scribe is UNCONFIGURED, and a failed request no longer discards
it. The remote arms answer "what was recorded"; this one answers "what
exists", and that question needs no instance to produce an answer. `curl ||
exit 0` became `curl || true` for the same reason.
Plugin 0.1.21 -> 0.1.22.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
---
plugin/.claude-plugin/plugin.json | 2 +-
plugin/hooks/scribe_prior_art.sh | 119 +++++++++++++++++++++++++-----
2 files changed, 102 insertions(+), 19 deletions(-)
diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json
index a486db8..b0fd4a3 100644
--- a/plugin/.claude-plugin/plugin.json
+++ b/plugin/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
- "version": "0.1.21",
+ "version": "0.1.22",
"author": { "name": "Bryan Van Deusen" },
"mcpServers": {
"scribe": {
diff --git a/plugin/hooks/scribe_prior_art.sh b/plugin/hooks/scribe_prior_art.sh
index 1b79998..027315b 100755
--- a/plugin/hooks/scribe_prior_art.sh
+++ b/plugin/hooks/scribe_prior_art.sh
@@ -48,16 +48,9 @@ case "$file_path" in
exit 0 ;;
esac
-url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
-token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
-# Guard against an unexpanded ${...} placeholder arriving as a literal.
-case "$url" in *'${'*) url="" ;; esac
-case "$token" in *'${'*) token="" ;; esac
-# Unconfigured install → silent. Prior-art recall is pure enrichment.
-[ -n "$url" ] && [ -n "$token" ] || exit 0
-
# Snippet locations are recorded repo-relative, so send a repo-relative path —
-# an absolute one would simply match nothing.
+# an absolute one would simply match nothing. Resolved BEFORE the config gate
+# because the local arm below needs the repo root and needs no server at all.
lookup_dir=$(dirname -- "$file_path" 2>/dev/null || true)
[ -d "$lookup_dir" ] || lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
repo_root=$(git -C "$lookup_dir" rev-parse --show-toplevel 2>/dev/null || true)
@@ -68,6 +61,84 @@ if [ -n "$repo_root" ]; then
esac
fi
+# ---------------------------------------------------------------------------
+# ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist?
+#
+# The other two arms ask Scribe what was RECORDED. Scribe has never read a line
+# of the codebase, so a helper nobody thought to record is invisible to them —
+# which is how `.btn-primary` came to be defined four times, in four scoped
+# stylesheets, already diverged. It was never a snippet, so no threshold and no
+# query rewrite could ever have surfaced it.
+#
+# This arm closes that by asking the only question the record cannot answer,
+# in the only place that can: the hook already runs on the developer's machine,
+# inside the repo, holding the code about to be written. No index, no storage,
+# no staleness, and no server — it deliberately runs even on an install that
+# has never configured Scribe.
+#
+# Definition-shaped patterns only. Grepping for bare occurrences would match
+# every CALL site and drown the real finding — and a hint that is mostly noise
+# is one people learn to skip, which is worse than none.
+# ---------------------------------------------------------------------------
+local_lines=""
+if [ -n "$repo_root" ] && [ -n "$code" ]; then
+ # kindname for each thing this payload DEFINES.
+ names=$(printf '%s' "$code" | awk '
+ match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/) {
+ t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t);
+ if (t != "") print "css\t" t; next }
+ match($0, /^[[:space:]]*(export[[:space:]]+)?(default[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) {
+ t = $0; sub(/^.*function[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t);
+ if (t != "") print "sym\t" t; next }
+ match($0, /^[[:space:]]*(export[[:space:]]+)?class[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) {
+ t = $0; sub(/^.*class[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t);
+ if (t != "") print "sym\t" t; next }
+ match($0, /^[[:space:]]*(async[[:space:]]+)?def[[:space:]]+[A-Za-z_][A-Za-z0-9_]*/) {
+ t = $0; sub(/^.*def[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_].*$/, "", t);
+ if (t != "") print "sym\t" t; next }
+ match($0, /^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/) {
+ t = $0; sub(/^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+/, "", t);
+ sub(/[^A-Za-z0-9_$].*$/, "", t);
+ if (t != "") print "sym\t" t; next }
+ ' 2>/dev/null | sort -u | head -12) || names=""
+
+ while IFS=$'\t' read -r kind name; do
+ [ -n "${name:-}" ] || continue
+ case "$kind" in
+ css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;;
+ *) pat="(function|class|def)[[:space:]]+${name}[^A-Za-z0-9_]|(const|let)[[:space:]]+${name}[[:space:]]*=" ;;
+ esac
+ # -I skips binaries; :(exclude) drops the file being written, which would
+ # otherwise always match itself on an Edit.
+ hits=$(git -C "$repo_root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel_path}" 2>/dev/null | head -4) || hits=""
+ [ -n "$hits" ] || continue
+ count=$(printf '%s\n' "$hits" | grep -c . 2>/dev/null || echo 0)
+ label=$([ "$kind" = css ] && printf '.%s' "$name" || printf '%s' "$name")
+ files=$(printf '%s' "$hits" | tr '\n' ' ' | sed 's/ $//')
+ local_lines="${local_lines}> - \`${label}\` is already defined in ${count} other file(s): ${files}"$'\n'
+ done <<< "$names"
+fi
+
+local_context=""
+if [ -n "$local_lines" ]; then
+ local_context="> Already defined elsewhere in this repo — check before adding another copy (\`git grep\` shown; this is a nudge, not a gate):"$'\n'"${local_lines}"
+fi
+
+url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
+token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
+# Guard against an unexpanded ${...} placeholder arriving as a literal.
+case "$url" in *'${'*) url="" ;; esac
+case "$token" in *'${'*) token="" ;; esac
+# Unconfigured install → the recorded-prior-art arms are skipped, but the local
+# arm above already ran and may have something to say.
+if [ -z "$url" ] || [ -z "$token" ]; then
+ if [ -n "$local_context" ]; then
+ jq -n --arg c "$local_context" \
+ '{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $c}}'
+ fi
+ exit 0
+fi
+
# Cap the code sent as the semantic query. The embedder truncates at its own
# token limit well before this, so a bigger slice buys no extra signal — and the
# payload has to stay a GET (a read-scoped API key cannot POST, and every other
@@ -110,20 +181,32 @@ if [ -n "$session_id" ]; then
fi
fi
+# `|| true`, not `|| exit 0`: an unreachable instance must not discard a local
+# finding that needed no instance to produce.
body=$(curl -fsS --max-time 5 \
-H "Authorization: Bearer ${token}" \
- "${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}" 2>/dev/null) || exit 0
-[ -n "$body" ] || exit 0
+ "${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}" 2>/dev/null) || body=""
-context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0
-[ -n "$context" ] || exit 0
-
-# Remember what was surfaced so it isn't shown again this session.
-if [ -n "$idfile" ]; then
- printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true
+context=""
+if [ -n "$body" ]; then
+ context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || context=""
+ # Remember what was surfaced so it isn't shown again this session.
+ if [ -n "$idfile" ] && [ -n "$context" ]; then
+ printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true
+ fi
fi
+# Local first. It answers "this already EXISTS", which is a stronger claim than
+# "this resembles something recorded" — and it is the one the recorded arms are
+# structurally unable to make.
+combined="$local_context"
+if [ -n "$context" ]; then
+ [ -n "$combined" ] && combined="${combined}"$'\n'
+ combined="${combined}${context}"
+fi
+[ -n "$combined" ] || exit 0
+
# No permissionDecision: this is a nudge, not a gate. The write goes ahead.
-jq -n --arg c "$context" \
+jq -n --arg c "$combined" \
'{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $c}}'
exit 0
From 6d01788326a8434793e6d6b6b0d8dccbf1ee3ec8 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Fri, 31 Jul 2026 23:49:10 -0400
Subject: [PATCH 03/12] test(plugin): pin both halves of the local prior-art
arm
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CI caught the previous commit: the fail-open contract asserts a hook stays
SILENT with no working instance, and the new local arm deliberately speaks
there. The invariant is right and the hook is right — the smoke event was
wrong. It used `def f`, which this repo really does define, so the hook found
something and "silent" was asserting the wrong thing.
Fixed by asserting the two properties separately:
- SILENT for a symbol that genuinely does not exist.
- SPEAKING, with NO credentials, for one that does. That is the point of the
arm — the other arms ask Scribe what was RECORDED; this one asks the repo
what EXISTS, which needs no instance. Were it to start depending on
configuration it would stop covering the case it was built for, and only
this assertion would notice.
Second trap, hit while fixing the first: spelling the absent symbol out in full
wrote `def (` into check_plugin.py, so the smoke event DEFINED the very
symbol it claimed was missing, and the hook found it again. The name is now
assembled from fragments so the contiguous string never appears in the source.
scribe_prior_art.sh joins scribe_session_context.sh as a hook that legitimately
produces output without credentials — for the same reason, that it carries
something needing neither network nor config.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
---
scripts/check_plugin.py | 57 ++++++++++++++++++++++++++++++++++++++++-
1 file changed, 56 insertions(+), 1 deletion(-)
diff --git a/scripts/check_plugin.py b/scripts/check_plugin.py
index 9fc42e2..22c706b 100755
--- a/scripts/check_plugin.py
+++ b/scripts/check_plugin.py
@@ -181,13 +181,25 @@ def check_shellcheck() -> None:
# pinning: the bug and the healthy no-results case look identical from outside.
# Pinning it does NOT make the failure visible; it makes sure the fail-open
# behaviour is deliberate rather than accidental.
+# A symbol that exists nowhere, ASSEMBLED rather than written literally.
+# The prior-art hook's local arm (#2280) fires with no credentials, so the
+# silence assertion below needs a name the repo genuinely lacks. Two traps,
+# both hit while writing this:
+# - `def f` matched real code, so the hook spoke and "silent" was asserting
+# the wrong thing;
+# - spelling the replacement out in full put `def (` INTO this file,
+# so the smoke event defined the very symbol it claimed was absent.
+# Concatenating keeps the contiguous string out of the source.
+_ABSENT_SYM = "zz" + "_absent_" + "9f3a2b"
+
SMOKE_EVENTS: dict[str, str] = {
"scribe_autoinject.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "prompt": "a multi-line\nprompt\nhere"}
),
"scribe_prior_art.sh": json.dumps(
{"session_id": "smoke", "cwd": ".", "tool_name": "Edit",
- "tool_input": {"file_path": "src/x.py", "new_string": "def f():\n pass\n"}}
+ "tool_input": {"file_path": "src/x.py",
+ "new_string": f"def {_ABSENT_SYM}():\n pass\n"}}
),
"scribe_sync_processes.sh": json.dumps({"source": "startup"}),
"scribe_session_context.sh": json.dumps({"source": "startup"}),
@@ -253,6 +265,48 @@ def check_fail_open() -> None:
ok(f"{rel} [{label}]: exit 0, silent")
+def check_local_prior_art_needs_no_instance() -> None:
+ """The prior-art hook's local arm must answer with no credentials (#2280).
+
+ The other arms ask Scribe what was RECORDED. This one asks the repo what
+ EXISTS, which needs no instance — and that is the whole reason it catches
+ the case the recorded arms structurally cannot: a helper nobody thought to
+ record. If it ever silently starts depending on configuration, it stops
+ covering that case and nothing else would notice.
+
+ Paired with the silence assertion in check_fail_open, which uses a symbol
+ that cannot exist. Together they pin both halves: silent when there is
+ nothing to say, and speaking when there is — both with no instance at all.
+ """
+ script = HOOKS_DIR / "scribe_prior_art.sh"
+ if not script.is_file() or not shutil.which("jq"):
+ skip("prior-art local arm: hook or jq missing")
+ return
+
+ # A definition this repo really does contain, written into a DIFFERENT file
+ # so the self-match exclusion doesn't suppress it.
+ event = json.dumps({
+ "session_id": "smoke", "cwd": ".", "tool_name": "Write",
+ "tool_input": {
+ "file_path": "scripts/_probe_not_real.py",
+ "content": "def check_local_prior_art_needs_no_instance():\n pass\n",
+ },
+ })
+ try:
+ proc = _run_hook(script, event, {}) # NO credentials, on purpose
+ except subprocess.TimeoutExpired:
+ fail("prior-art local arm: hung")
+ return
+ if proc.returncode != 0:
+ fail(f"prior-art local arm: exited {proc.returncode}, must be 0")
+ elif "already defined" not in proc.stdout:
+ fail("prior-art local arm: found nothing for a symbol this repo "
+ "defines, with no credentials — the arm that needs no instance "
+ "has stopped working, and the recorded arms cannot cover for it")
+ else:
+ ok("prior-art local arm: answers with no instance configured")
+
+
def _git(*args: str) -> tuple[int, str]:
proc = subprocess.run(
["git", *args], capture_output=True, text=True, cwd=ROOT
@@ -344,6 +398,7 @@ def main() -> int:
check_patterns()
check_shellcheck()
check_fail_open()
+ check_local_prior_art_needs_no_instance()
if not args.no_version:
check_version_bump(args.base)
From 1a959b1db028a93179a3452d859064472ca6b881 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Sat, 1 Aug 2026 19:52:35 -0400
Subject: [PATCH 04/12] refactor(ui): one button definition, aligned to the
design system
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`.btn-primary` was defined five times in five scoped stylesheets and all five
had drifted: three paddings, three font sizes, three disabled opacities — and
ProjectListView had no disabled style at all, so a disabled button there looked
enabled. Nothing detected any of it. A scoped duplicate is not a rule
violation, not a broken reference, and not a recorded snippet, so no existing
check could see it (#2273).
assets/components.css is now the single definition of the core four —
primary, secondary, ghost, danger — plus the small modifier, in design-system
tokens throughout. Operator's call to align to the system rather than to the
majority of current values, so buttons move to the 8px radius and 12px label
the system specifies, from the app's 4px/14.4px.
Class names are unchanged, so there are no template edits: the existing surface
is repurposed, not rebuilt.
STAGING PROPERTY that makes this safe to land ahead of the rest: a Vue
`
diff --git a/frontend/src/components/WorkspaceTaskPanel.vue b/frontend/src/components/WorkspaceTaskPanel.vue
index dff15cd..4f6d305 100644
--- a/frontend/src/components/WorkspaceTaskPanel.vue
+++ b/frontend/src/components/WorkspaceTaskPanel.vue
@@ -232,7 +232,7 @@ defineExpose({ reload: loadAll });
placeholder="New task..."
@keydown.enter="addTask"
/>
-
+