From 33f9a0a4d4f652df7683f07015c6006eff500768 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 14 Jun 2026 13:00:32 -0400 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20Phase=204=20=E2=80=94=20Scribe?= =?UTF-8?q?=20Processes=20auto-surface=20as=20local=20skills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #755 Phase 4. Saved Scribe Processes (DRY pass, Drift Audit, …) now surface as auto-triggered Claude Code skills instead of pull-only get_process calls. Design correction vs the plan: stubs live in the USER's ~/.claude/skills/, NOT plugin/skills/_instance/. The plugin is git-cloned and identical per install, so instance-specific generated files can't ride in it; personal skills are live-detected within the session (verified via claude-code-guide). MCP prompts were the alternative but are pull-only (no relevance auto-surface), so skills are the right primitive. - backend: GET /api/plugin/processes manifest (services/plugin_context. build_process_manifest) — {name, slug, description} per Process; description is the auto-surface trigger (title + preview); slugs deduped, blanks skipped. - plugin: scribe_sync_processes.sh writes ~/.claude/skills/scribe-proc-/ SKILL.md (body = "call get_process(name), follow verbatim") and PRUNES stale scribe-proc-* stubs. Fail-open + silent; a transient fetch failure never wipes existing stubs. Runs as a 2nd SessionStart hook + via the /scribe:sync command. - plugin.json 0.1.7 → 0.1.8; README updated. - tests: build_process_manifest (render, slug dedupe, blank-title skip, preview truncation). Sync script's write+prune validated in isolation (plugin/** is not CI-covered): correct stubs created, stale pruned, unrelated skills untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugin/.claude-plugin/plugin.json | 4 +- plugin/README.md | 8 +++ plugin/commands/sync.md | 20 +++++++ plugin/hooks/hooks.json | 4 ++ plugin/hooks/scribe_sync_processes.sh | 82 +++++++++++++++++++++++++++ src/scribe/routes/plugin.py | 14 +++++ src/scribe/services/plugin_context.py | 56 ++++++++++++++++++ tests/test_services_plugin_context.py | 51 +++++++++++++++++ 8 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 plugin/commands/sync.md create mode 100755 plugin/hooks/scribe_sync_processes.sh diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 63bc343..d3c5ec3 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", - "description": "Scribe second brain 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, and a set of process-skills (writing-plans, systematic-debugging, verification, brainstorming). Replaces superpowers + file-memory with one app-backed plugin.", - "version": "0.1.7", + "description": "Scribe second brain 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), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.", + "version": "0.1.8", "author": { "name": "Bryan Van Deusen" }, "mcpServers": { "scribe": { diff --git a/plugin/README.md b/plugin/README.md index 8b3ed7c..0fea71d 100644 --- a/plugin/README.md +++ b/plugin/README.md @@ -9,6 +9,10 @@ instance into a first-class Claude Code extension: rules + active-project context so Scribe surfaces *without being asked*. - **Universal process-skills** — brainstorm, systematic-debugging, TDD, writing-plans, verification, receiving-code-review (replaces superpowers). +- **Your Scribe Processes as skills** — saved Processes are synced into local + `~/.claude/skills/scribe-proc-*` stubs that auto-surface by relevance; the + stub fetches the live procedure via `get_process`. Refreshed each session and + on demand with `/scribe:sync`. It is designed so you can uninstall `superpowers` and disable auto-memory and depend on neither. @@ -38,6 +42,10 @@ On install you'll be asked for: **fail-open**: if Scribe is unreachable it injects nothing and never blocks the session. - `skills/` → the universal process-skills, surfaced by description match. +- `hooks/scribe_sync_processes.sh` (a 2nd SessionStart hook) + the `/scribe:sync` + command → generate `~/.claude/skills/scribe-proc-*` stubs from your Scribe + Processes (via `GET /api/plugin/processes`); also **fail-open**, and pruned to + match what exists in Scribe. ## Notes diff --git a/plugin/commands/sync.md b/plugin/commands/sync.md new file mode 100644 index 0000000..c7640e5 --- /dev/null +++ b/plugin/commands/sync.md @@ -0,0 +1,20 @@ +--- +description: Sync your Scribe stored Processes into auto-surfacing local skills +allowed-tools: Bash(bash:*), Bash(ls:*) +--- + +Regenerate the local skill stubs for your Scribe **Processes** so they auto-surface +this session. Each Process becomes a `~/.claude/skills/scribe-proc-/SKILL.md` +whose body calls `get_process()` for the live procedure. + +This normally runs automatically at session start; use it after adding or editing +a Process when you want it available immediately (Claude Code live-detects the new +skill files within this session — no restart needed). + +Run the bundled sync script, then list what was synced: + +``` +bash "${CLAUDE_PLUGIN_ROOT}/hooks/scribe_sync_processes.sh" && ls ~/.claude/skills 2>/dev/null | grep '^scribe-proc-' || echo "no Scribe process skills (no Processes, or Scribe unreachable)" +``` + +Report which `scribe-proc-*` skills are now present. diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index 76ce66e..23c40f9 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -6,6 +6,10 @@ { "type": "command", "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_session_context.sh\"" + }, + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_sync_processes.sh\"" } ] } diff --git a/plugin/hooks/scribe_sync_processes.sh b/plugin/hooks/scribe_sync_processes.sh new file mode 100755 index 0000000..d9bd7e3 --- /dev/null +++ b/plugin/hooks/scribe_sync_processes.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Scribe plugin — sync stored Processes into auto-surfacing local skills. +# +# Fetches `GET /api/plugin/processes` and writes one +# ~/.claude/skills/scribe-proc-/SKILL.md +# per Process. The stub's frontmatter `description` is the auto-surface trigger; +# its body tells Claude to call get_process() and follow the LIVE procedure +# from Scribe (the DB stays the single source of truth — the stub is a pointer). +# +# WHY LOCAL ~/.claude/skills (not the plugin dir): the plugin is git-cloned and +# identical on every install, so instance-specific stubs can't live inside it. +# Personal skills in ~/.claude/skills are live-detected by Claude Code within the +# session, so a freshly written stub auto-surfaces without a restart. +# +# TRIGGERS: this runs at SessionStart (alongside the context hook) so stubs stay +# fresh each session, and on demand via the `/scribe:sync` command. +# +# FAIL-OPEN & SILENT: never blocks a session; emits NOTHING on stdout (so it's +# safe as a second SessionStart hook). On any fetch failure it exits without +# touching existing stubs — a transient outage must not wipe the user's skills. +# Config mirrors the context hook (CLAUDE_PLUGIN_OPTION_* / SCRIBE_* override). +set -uo pipefail + +command -v jq >/dev/null 2>&1 || exit 0 +command -v curl >/dev/null 2>&1 || exit 0 + +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 +[ -n "$url" ] && [ -n "$token" ] || exit 0 + +body=$(curl -fsS --max-time 8 \ + -H "Authorization: Bearer ${token}" \ + "${url%/}/api/plugin/processes" 2>/dev/null) || exit 0 +[ -n "$body" ] || exit 0 + +count=$(printf '%s' "$body" | jq -r '.processes | length' 2>/dev/null) || exit 0 +[ -n "$count" ] && [ "$count" != "null" ] || exit 0 + +skills_dir="${HOME}/.claude/skills" +mkdir -p "$skills_dir" 2>/dev/null || exit 0 + +# Slugs written this run — anything else under scribe-proc-* is pruned below. +managed=" " + +i=0 +while [ "$i" -lt "$count" ]; do + name=$(printf '%s' "$body" | jq -r ".processes[$i].name // empty" 2>/dev/null) + slug=$(printf '%s' "$body" | jq -r ".processes[$i].slug // empty" 2>/dev/null) + desc=$(printf '%s' "$body" | jq -r ".processes[$i].description // empty" 2>/dev/null) + i=$((i + 1)) + [ -n "$slug" ] && [ -n "$name" ] || continue + + dir="${skills_dir}/scribe-proc-${slug}" + mkdir -p "$dir" 2>/dev/null || continue + # description folded to one line — YAML scalar must not contain a newline. + desc=$(printf '%s' "$desc" | tr '\n' ' ') + { + printf -- '---\n' + printf 'name: scribe-proc-%s\n' "$slug" + printf 'description: %s\n' "$desc" + printf -- '---\n\n' + printf '\n\n' + printf 'This is a saved **Scribe Process**: `%s`.\n\n' "$name" + printf 'Do not improvise it. Call `get_process("%s")` via the Scribe MCP server to fetch the live procedure, then follow the returned body verbatim — including any "clarify first" steps it contains.\n' "$name" + } > "${dir}/SKILL.md" 2>/dev/null || continue + managed="${managed}${slug} " +done + +# Prune stubs whose Process no longer exists (scoped to our scribe-proc-* prefix). +for d in "${skills_dir}"/scribe-proc-*; do + [ -d "$d" ] || continue + slug=$(basename "$d"); slug=${slug#scribe-proc-} + case "$managed" in + *" $slug "*) : ;; + *) rm -rf "$d" 2>/dev/null ;; + esac +done + +exit 0 diff --git a/src/scribe/routes/plugin.py b/src/scribe/routes/plugin.py index d470025..9dbf08a 100644 --- a/src/scribe/routes/plugin.py +++ b/src/scribe/routes/plugin.py @@ -57,6 +57,20 @@ async def session_context(): return jsonify(result) +@plugin_bp.get("/processes") +@login_required +async def process_manifest(): + """Stored Processes as skill-stub specs for the plugin's sync script. + + The plugin's `scribe_sync_processes.sh` (run at SessionStart and via the + `/scribe:sync` command) curls this and writes one auto-surfacing local skill + per Process into ~/.claude/skills/. See services/plugin_context. + build_process_manifest. A read-scoped API key suffices. + """ + result = await plugin_ctx_svc.build_process_manifest(g.user.id) + return jsonify(result) + + @plugin_bp.get("/marketplace-url") @login_required async def get_marketplace_url(): diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py index a86fcdd..e31d6bb 100644 --- a/src/scribe/services/plugin_context.py +++ b/src/scribe/services/plugin_context.py @@ -14,10 +14,13 @@ index alone already steers behavior. """ from __future__ import annotations +import re + from sqlalchemy import select from scribe.models import async_session from scribe.models.rulebook import RulebookTopic +from scribe.services import knowledge as knowledge_svc from scribe.services import notes as notes_svc from scribe.services import projects as projects_svc from scribe.services import rulebooks as rulebooks_svc @@ -25,6 +28,59 @@ from scribe.services import rulebooks as rulebooks_svc # Defensive cap below Claude Code's 10k additionalContext limit. _MAX_CHARS = 9000 +# Max chars of a Process body to fold into the auto-surface description. +_PROC_PREVIEW_CHARS = 200 + + +def _slugify(text: str) -> str: + """kebab-case slug for a skill directory name (a-z0-9 + single hyphens).""" + s = re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-") + return s or "process" + + +async def build_process_manifest(user_id: int) -> dict: + """List the user's stored Processes as auto-surfacing skill-stub specs. + + The plugin's sync script (scribe_sync_processes.sh) writes one + ~/.claude/skills/scribe-proc-/SKILL.md per entry — `description` is the + auto-surface trigger, and the stub body calls get_process(name) for the live + procedure (single source of truth in the DB). Reuses the list_processes query + (note_type='process'). Instance-agnostic: derived from whatever Processes the + calling install owns, no operator-specific coupling. + + Returns {"processes": [{id, name, slug, description}], "total": int}. + Slugs are unique within the result (a collision gets an - suffix). + """ + items, _ = await knowledge_svc.query_knowledge( + user_id=user_id, note_type="process", tags=[], sort="modified", + q=None, limit=100, offset=0, + ) + procs: list[dict] = [] + seen: set[str] = set() + for it in items: + title = (it.get("title") or "").strip() + if not title: + continue + slug = _slugify(title) + if slug in seen: + slug = f"{slug}-{it['id']}" + seen.add(slug) + + preview = " ".join((it.get("snippet") or "").split()) + if len(preview) > _PROC_PREVIEW_CHARS: + preview = preview[:_PROC_PREVIEW_CHARS].rstrip() + "…" + description = ( + f'Run the operator\'s saved Scribe process "{title}".' + + (f" {preview}" if preview else "") + + f' Use when {title}-type work is requested, or when asked to run' + f' the "{title}" process.' + ) + procs.append({ + "id": it["id"], "name": title, "slug": slug, + "description": description, + }) + return {"processes": procs, "total": len(procs)} + async def _topic_titles(topic_ids: set[int]) -> dict[int, str]: """Map topic_id -> title for the given ids (live topics only).""" diff --git a/tests/test_services_plugin_context.py b/tests/test_services_plugin_context.py index 4b2e888..63d96bd 100644 --- a/tests/test_services_plugin_context.py +++ b/tests/test_services_plugin_context.py @@ -74,6 +74,57 @@ async def test_build_session_context_unbound_repo_emits_bind_hint(): assert "## Active project" not in ctx +@pytest.mark.asyncio +async def test_build_process_manifest_renders_stub_specs(): + items = [ + {"id": 5, "title": "Drift Audit", "tags": [], "snippet": "Find drifted docs."}, + {"id": 9, "title": "DRY Pass", "tags": [], "snippet": ""}, + ] + with patch("scribe.services.plugin_context.knowledge_svc.query_knowledge", + AsyncMock(return_value=(items, 2))): + from scribe.services.plugin_context import build_process_manifest + out = await build_process_manifest(user_id=7) + + assert out["total"] == 2 + drift = out["processes"][0] + assert drift["id"] == 5 + assert drift["name"] == "Drift Audit" + assert drift["slug"] == "drift-audit" # kebab-cased + assert "Drift Audit" in drift["description"] # auto-surface trigger + assert "Find drifted docs." in drift["description"] # preview folded in + # No-snippet process still gets a usable description. + assert "DRY Pass" in out["processes"][1]["description"] + + +@pytest.mark.asyncio +async def test_build_process_manifest_dedupes_slugs_and_skips_blank_titles(): + items = [ + {"id": 1, "title": "My Process", "tags": [], "snippet": "a"}, + {"id": 2, "title": "my process", "tags": [], "snippet": "b"}, # same slug + {"id": 3, "title": " ", "tags": [], "snippet": "skip me"}, # blank title + ] + with patch("scribe.services.plugin_context.knowledge_svc.query_knowledge", + AsyncMock(return_value=(items, 3))): + from scribe.services.plugin_context import build_process_manifest + out = await build_process_manifest(user_id=7) + + slugs = [p["slug"] for p in out["processes"]] + assert slugs == ["my-process", "my-process-2"] # collision suffixed with id + assert out["total"] == 2 # blank-title entry dropped + + +@pytest.mark.asyncio +async def test_build_process_manifest_truncates_long_preview(): + items = [{"id": 1, "title": "Big", "tags": [], "snippet": "x" * 500}] + with patch("scribe.services.plugin_context.knowledge_svc.query_knowledge", + AsyncMock(return_value=(items, 1))): + from scribe.services.plugin_context import build_process_manifest + out = await build_process_manifest(user_id=7) + + assert "…" in out["processes"][0]["description"] + assert "x" * 500 not in out["processes"][0]["description"] + + @pytest.mark.asyncio async def test_build_session_context_caps_length(): many = [_rule(i, "x" * 200, 1) for i in range(200)]