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:
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user