33f9a0a4d4
#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>
140 lines
6.0 KiB
Python
140 lines
6.0 KiB
Python
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
def _rule(rid, title, topic_id):
|
|
r = MagicMock()
|
|
r.id, r.title, r.topic_id = rid, title, topic_id
|
|
r.statement = "FULL STATEMENT SHOULD NOT BE INJECTED"
|
|
return r
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_session_context_renders_titles_grouped_by_topic():
|
|
rules = [
|
|
_rule(1, "`dev` is home", 1),
|
|
_rule(2, "Release — never without explicit request", 1),
|
|
_rule(3, "No GitHub — Fabled-Git only", 2),
|
|
]
|
|
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
|
AsyncMock(return_value=rules)), \
|
|
patch("scribe.services.plugin_context._topic_titles",
|
|
AsyncMock(return_value={1: "git-workflow", 2: "fabled-git"})):
|
|
from scribe.services.plugin_context import build_session_context
|
|
out = await build_session_context(user_id=7, project_id=0)
|
|
|
|
ctx = out["context"]
|
|
assert out["rule_count"] == 3
|
|
assert out["project"] is None
|
|
# Titles present, grouped under topic headings
|
|
assert "### git-workflow" in ctx
|
|
assert "### fabled-git" in ctx
|
|
assert "- [1] `dev` is home" in ctx
|
|
assert "- [3] No GitHub — Fabled-Git only" in ctx
|
|
# Full statements must NOT be dumped (push channel injects titles only)
|
|
assert "FULL STATEMENT SHOULD NOT BE INJECTED" not in ctx
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_session_context_includes_project_when_scoped():
|
|
project = MagicMock(id=2, title="FabledScribe", goal="ship it")
|
|
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
|
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
|
|
patch("scribe.services.plugin_context._topic_titles",
|
|
AsyncMock(return_value={1: "git-workflow"})), \
|
|
patch("scribe.services.plugin_context.projects_svc.get_project",
|
|
AsyncMock(return_value=project)), \
|
|
patch("scribe.services.plugin_context.notes_svc.list_notes",
|
|
AsyncMock(return_value=([], 4))):
|
|
from scribe.services.plugin_context import build_session_context
|
|
out = await build_session_context(user_id=7, project_id=2)
|
|
|
|
assert out["project"] == {"id": 2, "title": "FabledScribe"}
|
|
assert "## Active project: FabledScribe (id 2)" in out["context"]
|
|
assert "Open todo tasks: 4" in out["context"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_session_context_unbound_repo_emits_bind_hint():
|
|
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
|
AsyncMock(return_value=[_rule(1, "rule", 1)])), \
|
|
patch("scribe.services.plugin_context._topic_titles",
|
|
AsyncMock(return_value={1: "git-workflow"})):
|
|
from scribe.services.plugin_context import build_session_context
|
|
out = await build_session_context(
|
|
user_id=7, project_id=0, unbound_repo="host/owner/repo",
|
|
)
|
|
|
|
ctx = out["context"]
|
|
assert out["project"] is None
|
|
assert "## Repository not yet bound" in ctx
|
|
assert 'bind_repo(repo_url="host/owner/repo"' in ctx
|
|
# No project block when unbound.
|
|
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)]
|
|
with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
|
|
AsyncMock(return_value=many)), \
|
|
patch("scribe.services.plugin_context._topic_titles",
|
|
AsyncMock(return_value={1: "git-workflow"})):
|
|
from scribe.services.plugin_context import build_session_context
|
|
out = await build_session_context(user_id=7)
|
|
|
|
assert len(out["context"]) <= 9000 + 60 # cap + truncation note
|
|
assert "truncated" in out["context"]
|