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:
@@ -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():
|
||||
|
||||
@@ -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-<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]:
|
||||
"""Map topic_id -> title for the given ids (live topics only)."""
|
||||
|
||||
Reference in New Issue
Block a user