feat(design-systems): give a design system a way to reach the session
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 31s

Storing a design system never made a session aware of one. Rules get pushed
into every session by the SessionStart hook and returned by enter_project; a
design system had neither, so its standards were reachable only by an agent
that already knew to call resolve_design_system — the same silent failure as a
token nobody declares.

That gap was invisible while the operator's visual standards also lived in a
rulebook. Retiring that rulebook (which is what this unblocks) would have
deleted design guidance from every session with nothing to say so.

- services/design_systems.design_context() — the delivery side. Guidance is
  chain-merged ANCESTOR-FIRST: a child system holds only what it CHANGES, so
  its own guidance describes a departure from a house style it never restates,
  and the leaf alone is a fragment. Tokens are summarised (count + group
  names), not listed — a hundred declarations would crowd out the context they
  are meant to inform.
- enter_project returns `design_system`, null when the project has none.
- The SessionStart context gains a Design system block with pointers to the
  values, alongside the always-on rules.
- server.py's entity list gains Design system, including the negative: do NOT
  record one as a rulebook, because a token kept as prose cannot be resolved,
  inherited, rendered or checked.
- The rulebook-tier passage used "a design-system rulebook" as its worked
  example of a subscribed rulebook — it now teaches the opposite, plus a new
  "is this a rule at all?" test pointing at design systems, processes and
  snippets.
- using-scribe gains a section on building UI against the project's system.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
This commit is contained in:
2026-07-31 22:10:09 -04:00
co-authored by Claude Opus 5
parent 1e139d0d18
commit 731ca284c3
8 changed files with 356 additions and 6 deletions
+21 -1
View File
@@ -104,7 +104,7 @@ shared homes general:
norms that bind *every* project. Cross-project standards only. norms that bind *every* project. Cross-project standards only.
- **Subscribed rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a - **Subscribed rulebook** (`create_rule` + `subscribe_project_to_rulebook`) — a
reusable, *themed* module of general rules that binds only projects that opt 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 - **Project rule** (`create_project_rule`) — anything specific to one project
(its files, paths, quirks). (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 norm → always-on rulebook. Never put project-specific detail in a shared
rulebook — it leaks to every other project that gets it. 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 ## Other Scribe process-skills
This plugin also ships focused process-skills — writing-plans, systematic This plugin also ships focused process-skills — writing-plans, systematic
+23 -2
View File
@@ -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 - Note: durable free-form knowledge — reference material, decisions, logs of
what happened. what happened.
No lifecycle, not actionable. Reach for one to CAPTURE something worth keeping. 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 - 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 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 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 subscribe_project_to_rulebook) — a reusable, THEMED module of general
rules that binds only the projects which subscribe. Its rules must make rules that binds only the projects which subscribe. Its rules must make
sense for every project that could subscribe, never one specific project sense for every project that could subscribe, never one specific project
(e.g. a design-system rulebook: design-specific but project-agnostic — no (e.g. a code-review checklist, or a compliance regime a category of
rule names a single app). projects shares — no rule names a single app).
- Project rule (create_project_rule) — anything specific to ONE project. - Project rule (create_project_rule) — anything specific to ONE project.
Both rulebook tiers are SHARED, so their rules stay general; the difference 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 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 standard a CATEGORY of projects shares -> subscribed rulebook; a universal
norm -> always-on rulebook. Coordinate with the operator on which home fits. 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 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 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 them unless its always-loaded context says to — but the bridge for that is the
+16 -1
View File
@@ -17,6 +17,7 @@ keeps working.
from __future__ import annotations from __future__ import annotations
from scribe.mcp._context import current_user_id 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 milestones as milestones_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
@@ -53,7 +54,13 @@ async def enter_project(project_id: int) -> dict:
Returns a dict with keys: project, milestone_summary, applicable_rules, Returns a dict with keys: project, milestone_summary, applicable_rules,
project_rules, subscribed_rulebooks, applicable_rules_truncated, 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() uid = current_user_id()
project = await projects_svc.get_project(uid, project_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, uid, is_task=False, project_id=project_id,
sort="updated_at", limit=5, 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 { return {
"project": project.to_dict(), "project": project.to_dict(),
"design_system": design_system,
"milestone_summary": milestone_summary, "milestone_summary": milestone_summary,
"applicable_rules": applicable["rules"], "applicable_rules": applicable["rules"],
"project_rules": applicable.get("project_rules", []), "project_rules": applicable.get("project_rules", []),
+62
View File
@@ -282,6 +282,68 @@ async def resolve_design_system(
return resolve_tokens(design_system_id, parents, tokens_by_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( async def update_token(
user_id: int, token_id: int, **fields: object user_id: int, token_id: int, **fields: object
) -> DesignToken | None: ) -> DesignToken | None:
+32
View File
@@ -22,6 +22,7 @@ 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 design_systems as design_systems_svc
from scribe.services import knowledge as knowledge_svc 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
@@ -837,6 +838,37 @@ async def build_session_context(
f"Goal: {goal[:200]}" if goal else "", f"Goal: {goal[:200]}" if goal else "",
f"Open todo tasks: {open_count}", 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: elif unbound_repo:
lines += [ lines += [
"", "",
+44 -1
View File
@@ -17,12 +17,16 @@ def _bind_user():
_user_id_ctx.reset(token) _user_id_ctx.reset(token)
def _fake_project(**overrides) -> MagicMock: def _fake_project(design_system_id=None, **overrides) -> MagicMock:
p = MagicMock() p = MagicMock()
base = {"id": 1, "title": "P", "description": "", "goal": "", base = {"id": 1, "title": "P", "description": "", "goal": "",
"status": "active", "color": None} "status": "active", "color": None}
base.update(overrides) base.update(overrides)
p.to_dict.return_value = base 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 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]["id"] == 100
assert out["open_tasks"][0]["status"] == "in_progress" assert out["open_tasks"][0]["status"] == "in_progress"
assert out["recent_notes"][0]["id"] == 200 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 @pytest.mark.asyncio
+91
View File
@@ -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 result["duplicates"] == {"#4a5d3f": ["--fs-moss", "--fs-success"]}
assert "--fs-moss: #4a5d3f;" in result["css"] assert "--fs-moss: #4a5d3f;" in result["css"]
assert "FabledSword" 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
+67 -1
View File
@@ -130,7 +130,11 @@ async def test_build_session_context_renders_titles_grouped_by_topic():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_build_session_context_includes_project_when_scoped(): 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", with patch("scribe.services.plugin_context.rulebooks_svc.list_always_on_rules",
AsyncMock(return_value=[_rule(1, "rule", 1)])), \ AsyncMock(return_value=[_rule(1, "rule", 1)])), \
patch("scribe.services.plugin_context._topic_titles", 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 out["project"] == {"id": 2, "title": "FabledScribe"}
assert "## Active project: FabledScribe (id 2)" in out["context"] assert "## Active project: FabledScribe (id 2)" in out["context"]
assert "Open todo tasks: 4" 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 @pytest.mark.asyncio