feat(plugin): Phase 4 — Scribe Processes auto-surface as local skills
#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-<slug>/ 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) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"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.",
|
"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.7",
|
"version": "0.1.8",
|
||||||
"author": { "name": "Bryan Van Deusen" },
|
"author": { "name": "Bryan Van Deusen" },
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"scribe": {
|
"scribe": {
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ instance into a first-class Claude Code extension:
|
|||||||
rules + active-project context so Scribe surfaces *without being asked*.
|
rules + active-project context so Scribe surfaces *without being asked*.
|
||||||
- **Universal process-skills** — brainstorm, systematic-debugging, TDD,
|
- **Universal process-skills** — brainstorm, systematic-debugging, TDD,
|
||||||
writing-plans, verification, receiving-code-review (replaces superpowers).
|
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
|
It is designed so you can uninstall `superpowers` and disable auto-memory and
|
||||||
depend on neither.
|
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
|
**fail-open**: if Scribe is unreachable it injects nothing and never blocks
|
||||||
the session.
|
the session.
|
||||||
- `skills/` → the universal process-skills, surfaced by description match.
|
- `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
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -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-<slug>/SKILL.md`
|
||||||
|
whose body calls `get_process(<name>)` 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.
|
||||||
@@ -6,6 +6,10 @@
|
|||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_session_context.sh\""
|
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_session_context.sh\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_sync_processes.sh\""
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+82
@@ -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-<slug>/SKILL.md
|
||||||
|
# per Process. The stub's frontmatter `description` is the auto-surface trigger;
|
||||||
|
# its body tells Claude to call get_process(<name>) 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 '<!-- GENERATED by the Scribe plugin (scribe_sync_processes.sh). Do not edit here; edit the Process in Scribe and re-sync with /scribe:sync. -->\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
|
||||||
@@ -57,6 +57,20 @@ async def session_context():
|
|||||||
return jsonify(result)
|
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")
|
@plugin_bp.get("/marketplace-url")
|
||||||
@login_required
|
@login_required
|
||||||
async def get_marketplace_url():
|
async def get_marketplace_url():
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ index alone already steers behavior.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from scribe.models import async_session
|
from scribe.models import async_session
|
||||||
from scribe.models.rulebook import RulebookTopic
|
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 notes as notes_svc
|
||||||
from scribe.services import projects as projects_svc
|
from scribe.services import projects as projects_svc
|
||||||
from scribe.services import rulebooks as rulebooks_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.
|
# Defensive cap below Claude Code's 10k additionalContext limit.
|
||||||
_MAX_CHARS = 9000
|
_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-<slug>/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 -<id> 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]:
|
async def _topic_titles(topic_ids: set[int]) -> dict[int, str]:
|
||||||
"""Map topic_id -> title for the given ids (live topics only)."""
|
"""Map topic_id -> title for the given ids (live topics only)."""
|
||||||
|
|||||||
@@ -74,6 +74,57 @@ 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_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
|
@pytest.mark.asyncio
|
||||||
async def test_build_session_context_caps_length():
|
async def test_build_session_context_caps_length():
|
||||||
many = [_rule(i, "x" * 200, 1) for i in range(200)]
|
many = [_rule(i, "x" * 200, 1) for i in range(200)]
|
||||||
|
|||||||
Reference in New Issue
Block a user