feat(systems): evidence-carrying bootstrap ask for mature zero-Systems projects (#2683)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 39s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 24s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 1m4s
CI & Build / Build & push image (push) Successful in 39s
The generic zero-state systems_hint never converts: identical on every record, maximal in scope, asked at wrap-up time — Minstrel reached 282 records with zero Systems while vocabularied projects grew organically. What converts is the project's own evidence at the moment of action. bootstrap_systems_ask (mcp/tools/systems.py) fires only in a project with >=20 records and no Systems: it names the record count and recent titles, and asks for a concrete deliverable — propose 3-6 Systems, confirm with the operator, create_system the set. Self-retiring: the first System ends it everywhere. Wired at both moments the task named: untagged_systems_hint escalates to it at write time, and enter_project carries it as systems_bootstrap at arrival (attached only when it applies). Young projects keep the mild question; populated vocabularies never pay the count query. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,18 @@ def _no_coverage():
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_bootstrap():
|
||||
"""With _no_systems stubbing an empty vocabulary, every test here reaches
|
||||
the zero-Systems branch, whose bootstrap ask (#2683) counts the project's
|
||||
records — a database read. Stub the common case (young project, no ask);
|
||||
the firing shape has its own test below.
|
||||
"""
|
||||
with patch("scribe.mcp.tools.projects.systems_tools.bootstrap_systems_ask",
|
||||
AsyncMock(return_value=None)) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def _fake_project(design_system_id=None, **overrides) -> MagicMock:
|
||||
p = MagicMock()
|
||||
base = {"id": 1, "title": "P", "description": "", "goal": "",
|
||||
@@ -211,6 +223,9 @@ async def test_enter_project_composes_full_context():
|
||||
# vocabulary, and "this project has no named areas yet" is information the
|
||||
# create-the-System instruction acts on.
|
||||
assert out["systems"] == []
|
||||
# Young project, no bootstrap ask -> the key is ABSENT, not null: it exists
|
||||
# to be acted on, and a key that usually says null gets skipped (#2483).
|
||||
assert "systems_bootstrap" not in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -251,6 +266,62 @@ async def test_enter_project_surfaces_the_systems_vocabulary():
|
||||
]
|
||||
|
||||
|
||||
def _enter_project_stubs(p):
|
||||
"""The four patches every enter_project test repeats, as one context list."""
|
||||
return [
|
||||
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)])),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enter_project_carries_the_bootstrap_ask_when_it_fires():
|
||||
"""The arrival-moment half of #2683: a mature zero-Systems project greets
|
||||
the session with the concrete bootstrap ask, before it is deep in a task —
|
||||
the moment "propose a starter vocabulary" is cheapest."""
|
||||
import contextlib
|
||||
|
||||
ask = "This project has 282 records and NO Systems modelled — ..."
|
||||
with contextlib.ExitStack() as stack:
|
||||
for cm in _enter_project_stubs(_fake_project(id=5)):
|
||||
stack.enter_context(cm)
|
||||
stack.enter_context(patch(
|
||||
"scribe.mcp.tools.projects.systems_tools.bootstrap_systems_ask",
|
||||
AsyncMock(return_value=ask),
|
||||
))
|
||||
out = await enter_project(project_id=5)
|
||||
assert out["systems_bootstrap"] == ask
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enter_project_never_asks_bootstrap_once_a_vocabulary_exists(
|
||||
_no_bootstrap,
|
||||
):
|
||||
"""The ask is self-retiring: the first System ends it — enter_project must
|
||||
not even evaluate it once the vocabulary is non-empty."""
|
||||
import contextlib
|
||||
|
||||
sys1 = MagicMock()
|
||||
sys1.id = 3; sys1.name = "retrieval"; sys1.description = ""
|
||||
with contextlib.ExitStack() as stack:
|
||||
for cm in _enter_project_stubs(_fake_project(id=5)):
|
||||
stack.enter_context(cm)
|
||||
stack.enter_context(patch(
|
||||
"scribe.mcp.tools.projects.systems_svc.list_systems",
|
||||
AsyncMock(return_value=[sys1]),
|
||||
))
|
||||
out = await enter_project(project_id=5)
|
||||
assert "systems_bootstrap" not in out
|
||||
_no_bootstrap.assert_not_awaited()
|
||||
|
||||
|
||||
@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
|
||||
|
||||
Reference in New Issue
Block a user