diff --git a/plugin/skills/using-scribe/SKILL.md b/plugin/skills/using-scribe/SKILL.md index 43184ea..8562d58 100644 --- a/plugin/skills/using-scribe/SKILL.md +++ b/plugin/skills/using-scribe/SKILL.md @@ -104,7 +104,7 @@ shared homes general: norms that bind *every* project. Cross-project standards only. - **Subscribed rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a reusable, *themed* module of general rules that binds only projects that opt - in (e.g. a design system → visual apps). Themed, but still project-agnostic. + in (e.g. a review checklist → every service). Themed, but project-agnostic. - **Project rule** (`create_project_rule`) — anything specific to one project (its files, paths, quirks). @@ -114,6 +114,26 @@ project rule; a standard a category shares → subscribed rulebook; a universal norm → always-on rulebook. Never put project-specific detail in a shared rulebook — it leaks to every other project that gets it. +**First ask whether it's a rule at all.** A rule is prose you have to remember +and apply; Scribe's other entities are structure a tool can resolve and check. +Visual standards belong in a **design system**, not a rulebook — a token can be +inherited, resolved per mode, rendered to a stylesheet and diffed against code, +and none of that survives being written as a rule. A repeatable procedure is a +**process**; reusable code is a **snippet**. Reach for a rule when the thing +really is a standing instruction about how to work. + +## Building UI: the project's design system binds + +`enter_project` returns a `design_system` when the project has one, with the +guidance **chain-merged** — the house style it inherits plus its own departures +from it. Treat it the way you treat a rule. + +Before writing a colour, size, radius, weight or duration by hand, reach for a +token: `resolve_design_system(id)` for the values, or +`get_design_system_stylesheet(id)` for the rendered sheet. A literal is a value +stated outside the system, so it can never follow a palette change — and +nothing will tell you it drifted. + ## Other Scribe process-skills This plugin also ships focused process-skills — writing-plans, systematic diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index 79886dd..7a7f55b 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -42,6 +42,18 @@ What each part is for, and when to reach for it: - Note: durable free-form knowledge — reference material, decisions, logs of what happened. No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping. +- Design system: the visual standards a project's UI is built from — design + tokens (name + value per mode) plus the prose a token table cannot hold + (aesthetic, voice, what is out of scope). Systems INHERIT: a child holds only + what it changes and the chain supplies the rest, so a family's house style and + one app's departures from it are the same structure at two depths. A project + points at one with set_project_design_system, and enter_project then hands it + back with the guidance chain-merged. Treat it as binding for UI work: reach + for a token (resolve_design_system / get_design_system_stylesheet) before + writing a colour, size, radius or duration by hand. Do NOT record a design + system as a rulebook — rules are for behaviour, and tokens kept as prose + cannot be resolved, inherited, rendered to a stylesheet, or checked against + code. - System: a per-project, reusable, self-describing subsystem/area. Associate any record (note, task, issue) with it via system_ids so research, build-work, and fixes for the same area line up, and recurring problem-spots surface. Manage @@ -144,8 +156,8 @@ right altitude: subscribe_project_to_rulebook) — a reusable, THEMED module of general rules that binds only the projects which subscribe. Its rules must make sense for every project that could subscribe, never one specific project - (e.g. a design-system rulebook: design-specific but project-agnostic — no - rule names a single app). + (e.g. a code-review checklist, or a compliance regime a category of + projects shares — no rule names a single app). - Project rule (create_project_rule) — anything specific to ONE project. Both rulebook tiers are SHARED, so their rules stay general; the difference between them is REACH (all projects vs opt-in by theme), not generality. Rule @@ -153,6 +165,15 @@ of thumb: names a specific project's files/paths/quirks -> project rule; a standard a CATEGORY of projects shares -> subscribed rulebook; a universal norm -> always-on rulebook. Coordinate with the operator on which home fits. +Before writing a rule, check whether another entity already models the thing. +A rule is prose an agent must remember and apply; the other entities are +structure a tool can resolve, render and check. Visual standards are a DESIGN +SYSTEM, not a rulebook — a token can be inherited, resolved per mode, rendered +to a stylesheet and diffed against code, and none of that survives being +written as a rule. A repeatable procedure is a PROCESS. Reusable code is a +SNIPPET. Reach for a rule when the thing genuinely is a standing instruction +about how to work, and nothing else can hold it. + One thing NOT to do: don't bridge Scribe into a session by writing to the host's native memory. Rules are pull-only, so a fresh session won't reach for them unless its always-loaded context says to — but the bridge for that is the diff --git a/src/scribe/mcp/tools/projects.py b/src/scribe/mcp/tools/projects.py index 7557074..3d26ab1 100644 --- a/src/scribe/mcp/tools/projects.py +++ b/src/scribe/mcp/tools/projects.py @@ -17,6 +17,7 @@ keeps working. from __future__ import annotations from scribe.mcp._context import current_user_id +from scribe.services import design_systems as design_systems_svc from scribe.services import milestones as milestones_svc from scribe.services import notes as notes_svc from scribe.services import projects as projects_svc @@ -53,7 +54,13 @@ async def enter_project(project_id: int) -> dict: Returns a dict with keys: project, milestone_summary, applicable_rules, project_rules, subscribed_rulebooks, applicable_rules_truncated, - open_tasks, recent_notes. + open_tasks, recent_notes, design_system. + + `design_system` is null unless the project points at one. When present it + carries the chain-merged guidance (the house style AND this project's + departures from it) plus a summary of the token set — treat it as binding + for any UI you write, and pull the values with resolve_design_system or + get_design_system_stylesheet before reaching for a literal. """ uid = current_user_id() project = await projects_svc.get_project(uid, project_id) @@ -74,9 +81,17 @@ async def enter_project(project_id: int) -> dict: uid, is_task=False, project_id=project_id, sort="updated_at", limit=5, ) + # A project need not have one, and most installs won't — null is ordinary + # here, not a missing prerequisite. + design_system = None + if project.design_system_id: + design_system = await design_systems_svc.design_context( + uid, project.design_system_id, + ) return { "project": project.to_dict(), + "design_system": design_system, "milestone_summary": milestone_summary, "applicable_rules": applicable["rules"], "project_rules": applicable.get("project_rules", []), diff --git a/src/scribe/services/design_systems.py b/src/scribe/services/design_systems.py index 4c4ef35..e4bb63b 100644 --- a/src/scribe/services/design_systems.py +++ b/src/scribe/services/design_systems.py @@ -282,6 +282,68 @@ async def resolve_design_system( return resolve_tokens(design_system_id, parents, tokens_by_system) +async def design_context(user_id: int, design_system_id: int) -> dict | None: + """What a session needs to know about a design system, before it writes UI. + + This is the DELIVERY side of a design system, and it exists because storing + one does not make a session aware of it. Rules get pushed into every session + by the plugin's SessionStart hook; a design system had no such channel, so + the standards were reachable only by an agent that already knew to go + looking — which is the same silent failure as a token nobody declares. + + Guidance is chain-merged, ANCESTOR-FIRST, and that is the point rather than + a convenience. A child system holds only what it CHANGES, so its own + guidance describes a departure from a house style it never restates. Hand an + agent the leaf alone and it builds against a fragment, with no signal that + the rest exists. + + Tokens are summarised, not listed: the count and the group names are enough + to know what the system covers, and the full set is one call away. Sending + a hundred token values into every session start would crowd out the context + it is meant to inform. + + None when the caller may not read the system. + """ + tokens = await resolve_design_system(user_id, design_system_id) + if tokens is None: + return None + + async with async_session() as session: + system = await session.get(DesignSystem, design_system_id) + if system is None or system.deleted_at is not None: + return None + parents = await _parent_map(session, system.owner_user_id) + chain = ancestry(design_system_id, parents) + rows = ( + await session.execute( + select(DesignSystem).where(DesignSystem.id.in_(chain)) + ) + ).scalars().all() + + by_id = {s.id: s for s in rows} + return { + "id": system.id, + "title": system.title, + "description": system.description or "", + # Outermost ancestor first, so the reader meets the house style before + # the app's departures from it. + "inherits_from": [ + by_id[sid].title for sid in reversed(chain[1:]) if sid in by_id + ], + "guidance": [ + { + "design_system_id": sid, + "title": by_id[sid].title, + "guidance": (by_id[sid].guidance or "").strip(), + } + for sid in reversed(chain) + if sid in by_id and (by_id[sid].guidance or "").strip() + ], + "token_count": len(tokens), + "token_groups": sorted({t.group_name for t in tokens if t.group_name}), + } + + async def update_token( user_id: int, token_id: int, **fields: object ) -> DesignToken | None: diff --git a/src/scribe/services/plugin_context.py b/src/scribe/services/plugin_context.py index 93eda36..f07d311 100644 --- a/src/scribe/services/plugin_context.py +++ b/src/scribe/services/plugin_context.py @@ -22,6 +22,7 @@ from sqlalchemy import select from scribe.models import async_session from scribe.models.rulebook import RulebookTopic +from scribe.services import design_systems as design_systems_svc from scribe.services import knowledge as knowledge_svc from scribe.services import notes as notes_svc from scribe.services import projects as projects_svc @@ -837,6 +838,37 @@ async def build_session_context( f"Goal: {goal[:200]}" if goal else "", f"Open todo tasks: {open_count}", ] + + # A design system binds the same way a rule does, and until this + # existed it had no push channel — the standards were reachable only + # by an agent that already knew to look for them. Summary only: the + # token VALUES are a tool call away, and pasting a hundred of them + # into every session would crowd out the context they inform. + if project.design_system_id: + design = await design_systems_svc.design_context( + user_id, project.design_system_id, + ) + if design: + inherits = ( + " (inherits " + " › ".join(design["inherits_from"]) + ")" + if design["inherits_from"] else "" + ) + groups = ", ".join(design["token_groups"]) + lines += [ + "", + f"## Design system: {design['title']} " + f"(id {design['id']}){inherits}", + f"{design['token_count']} tokens" + + (f" across {groups}" if groups else "") + + ". This project's UI is built from these, not from " + "literals — reach for a token before writing a colour, " + "size, radius or duration by hand.", + f"Values: `resolve_design_system({design['id']})` · " + f"stylesheet: `get_design_system_stylesheet({design['id']})` " + f"· the prose (aesthetic, voice, where the accent may " + f"appear): `enter_project` returns it, or " + f"`get_design_system({design['id']})`.", + ] elif unbound_repo: lines += [ "", diff --git a/tests/test_mcp_tool_projects.py b/tests/test_mcp_tool_projects.py index 5328f74..3b52c4e 100644 --- a/tests/test_mcp_tool_projects.py +++ b/tests/test_mcp_tool_projects.py @@ -17,12 +17,16 @@ def _bind_user(): _user_id_ctx.reset(token) -def _fake_project(**overrides) -> MagicMock: +def _fake_project(design_system_id=None, **overrides) -> MagicMock: p = MagicMock() base = {"id": 1, "title": "P", "description": "", "goal": "", "status": "active", "color": None} base.update(overrides) p.to_dict.return_value = base + # Explicit, because a bare MagicMock hands back a truthy auto-attribute — + # which would route every project in this file through the design-system + # branch and out to a real database. + p.design_system_id = design_system_id return p @@ -176,6 +180,45 @@ async def test_enter_project_composes_full_context(): assert out["open_tasks"][0]["id"] == 100 assert out["open_tasks"][0]["status"] == "in_progress" assert out["recent_notes"][0]["id"] == 200 + # No design system on this project -> the key is present and null, not + # absent. A caller that has to distinguish "no key" from "no system" will + # eventually get it wrong. + assert out["design_system"] is None + + +@pytest.mark.asyncio +async def test_enter_project_hands_back_the_design_system_when_the_project_has_one(): + """The handshake is where an agent learns what binds it, and a design + system binds the same way a rule does. Before this it was reachable only by + an agent that already knew to call resolve_design_system — so the standards + were present in the store and absent from the work.""" + p = _fake_project(id=5, design_system_id=9) + design = {"id": 9, "title": "App kit", "guidance": [{"title": "House"}], + "token_count": 95, "token_groups": ["surface"], + "inherits_from": ["House"], "description": ""} + + with patch( + "scribe.mcp.tools.projects.projects_svc.get_project", + AsyncMock(return_value=p), + ), patch( + "scribe.mcp.tools.projects.rulebooks_svc.get_applicable_rules", + AsyncMock(return_value={"rules": [], "truncated": False, + "subscribed_rulebooks": []}), + ), patch( + "scribe.mcp.tools.projects.milestones_svc.get_project_milestone_summary", + AsyncMock(return_value=[]), + ), patch( + "scribe.mcp.tools.projects.notes_svc.list_notes", + AsyncMock(side_effect=[([], 0), ([], 0)]), + ), patch( + "scribe.mcp.tools.projects.design_systems_svc.design_context", + AsyncMock(return_value=design), + ) as ctx: + out = await enter_project(project_id=5) + + assert out["design_system"]["token_count"] == 95 + assert out["design_system"]["inherits_from"] == ["House"] + assert ctx.await_args.args == (7, 9) # caller's id, the project's system @pytest.mark.asyncio diff --git a/tests/test_services_design_systems.py b/tests/test_services_design_systems.py index 83f0339..303ff31 100644 --- a/tests/test_services_design_systems.py +++ b/tests/test_services_design_systems.py @@ -321,3 +321,94 @@ async def test_stylesheet_reports_what_the_sheet_cannot_say_for_itself(): assert result["duplicates"] == {"#4a5d3f": ["--fs-moss", "--fs-success"]} assert "--fs-moss: #4a5d3f;" in result["css"] assert "FabledSword" in result["css"] + + +# --- design_context: the delivery side -------------------------------------- + +def _ctx_token(name: str, group: str | None): + from scribe.services.design_cascade import ResolvedToken + return ResolvedToken( + name=name, contributions={}, group_name=group, + purpose=None, rationale=None, supersedes=(), order_index=0, + ) + + +@pytest.mark.asyncio +async def test_design_context_merges_guidance_ANCESTOR_FIRST(): + """LOAD-BEARING. A child system holds only what it CHANGES, so its own + guidance describes a departure from a house style it never restates. An + agent handed the leaf alone builds against a fragment with no signal that + the rest exists — which is exactly the failure retiring the design rulebook + would otherwise have caused. + """ + family = MagicMock(id=1, deleted_at=None, owner_user_id=42, + description="the house", guidance="Dark-mode-first.") + family.title = "House" + app = MagicMock(id=3, deleted_at=None, owner_user_id=42, + description="one app", guidance="Accent on the wordmark.") + app.title = "App" + + mock_session = _make_mock_session() + mock_session.get = AsyncMock(return_value=app) + mock_session.execute = AsyncMock(return_value=MagicMock( + scalars=MagicMock(return_value=MagicMock(all=lambda: [app, family])) + )) + + with patch("scribe.services.design_systems.async_session") as mock_cls, \ + patch("scribe.services.design_systems._parent_map", + AsyncMock(return_value={3: 1, 1: None})), \ + patch("scribe.services.design_systems.resolve_design_system", + AsyncMock(return_value=[ + _ctx_token("--a", "surface"), _ctx_token("--b", "type"), + _ctx_token("--c", "surface"), _ctx_token("--d", None), + ])): + mock_cls.return_value = mock_session + from scribe.services.design_systems import design_context + out = await design_context(user_id=42, design_system_id=3) + + assert [g["title"] for g in out["guidance"]] == ["House", "App"] + assert out["inherits_from"] == ["House"] + assert out["token_count"] == 4 + # Group names deduped and sorted; an ungrouped token contributes nothing. + assert out["token_groups"] == ["surface", "type"] + + +@pytest.mark.asyncio +async def test_design_context_denied_returns_none(): + """It rides on resolve_design_system's ACL rather than re-deriving one — + a second permission path is a second thing to get wrong.""" + with patch("scribe.services.design_systems.resolve_design_system", + AsyncMock(return_value=None)): + from scribe.services.design_systems import design_context + assert await design_context(user_id=1, design_system_id=3) is None + + +@pytest.mark.asyncio +async def test_design_context_omits_systems_with_no_guidance(): + """A system that overrides one token and says nothing about it should not + contribute an empty section — a heading with nothing under it reads as + missing content rather than as an absence of content.""" + family = MagicMock(id=1, deleted_at=None, owner_user_id=42, + description="", guidance="Dark-mode-first.") + family.title = "House" + app = MagicMock(id=3, deleted_at=None, owner_user_id=42, + description="", guidance=" ") + app.title = "App" + + mock_session = _make_mock_session() + mock_session.get = AsyncMock(return_value=app) + mock_session.execute = AsyncMock(return_value=MagicMock( + scalars=MagicMock(return_value=MagicMock(all=lambda: [app, family])) + )) + + with patch("scribe.services.design_systems.async_session") as mock_cls, \ + patch("scribe.services.design_systems._parent_map", + AsyncMock(return_value={3: 1, 1: None})), \ + patch("scribe.services.design_systems.resolve_design_system", + AsyncMock(return_value=[])): + mock_cls.return_value = mock_session + from scribe.services.design_systems import design_context + out = await design_context(user_id=42, design_system_id=3) + + assert [g["title"] for g in out["guidance"]] == ["House"] + assert out["token_count"] == 0 diff --git a/tests/test_services_plugin_context.py b/tests/test_services_plugin_context.py index 42c19b6..e73ca26 100644 --- a/tests/test_services_plugin_context.py +++ b/tests/test_services_plugin_context.py @@ -130,7 +130,11 @@ async def test_build_session_context_renders_titles_grouped_by_topic(): @pytest.mark.asyncio async def test_build_session_context_includes_project_when_scoped(): - project = MagicMock(id=2, title="FabledScribe", goal="ship it") + # design_system_id explicitly None: a bare MagicMock would hand back a truthy + # auto-attribute and send this through the design branch, which is the + # opposite of what this test is about. + project = MagicMock(id=2, title="FabledScribe", goal="ship it", + design_system_id=None) 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", @@ -145,6 +149,68 @@ async def test_build_session_context_includes_project_when_scoped(): assert out["project"] == {"id": 2, "title": "FabledScribe"} assert "## Active project: FabledScribe (id 2)" in out["context"] assert "Open todo tasks: 4" in out["context"] + # No design system on the project -> no design block at all. An install with + # none is the ordinary case, not a degraded one. + assert "## Design system" not in out["context"] + + +@pytest.mark.asyncio +async def test_build_session_context_pushes_the_projects_design_system(): + """The gap this closes: a design system had no push channel, so its + standards reached a session only if the agent already knew to go looking — + the same silent failure as a token nobody declares.""" + project = MagicMock(id=2, title="App", goal="", design_system_id=9) + design = { + "id": 9, "title": "App kit", "description": "", + "inherits_from": ["House"], + "guidance": [], "token_count": 95, + "token_groups": ["accent", "surface", "type"], + } + 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=([], 0))), \ + patch("scribe.services.plugin_context.design_systems_svc.design_context", + AsyncMock(return_value=design)): + from scribe.services.plugin_context import build_session_context + out = await build_session_context(user_id=7, project_id=2) + + ctx = out["context"] + assert "## Design system: App kit (id 9) (inherits House)" in ctx + assert "95 tokens across accent, surface, type" in ctx + # A pointer to the values, never the values themselves — a hundred token + # declarations would crowd out the context they are meant to inform. The + # summary carries counts and group NAMES only, which is why design_context + # returns those rather than the resolved tokens. + assert "resolve_design_system(9)" in ctx + assert "get_design_system_stylesheet(9)" in ctx + + +@pytest.mark.asyncio +async def test_build_session_context_survives_an_unreadable_design_system(): + """design_context returns None when the caller may not read the system. + That must degrade to "no design block", not to a crash that costs the + session its rules too.""" + project = MagicMock(id=2, title="App", goal="", design_system_id=9) + 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=([], 0))), \ + patch("scribe.services.plugin_context.design_systems_svc.design_context", + AsyncMock(return_value=None)): + from scribe.services.plugin_context import build_session_context + out = await build_session_context(user_id=7, project_id=2) + + assert "## Design system" not in out["context"] + assert "## Active project: App (id 2)" in out["context"] @pytest.mark.asyncio