feat(rules): enter_project handshake (S4)
New enter_project(project_id) MCP tool composes get_project + get_applicable_rules + get_project_milestone_summary + recent open-tasks + recent notes into one round-trip, intended to be called at session start (or whenever the active project changes) so Claude has the full project context loaded before it starts mutating. _INSTRUCTIONS now points Claude at enter_project for project-scoped work, alongside the existing list_always_on_rules instruction. No schema change; pure composition over existing services. Closes the four-slice rules-consolidation plan (Scribe task #508): S1+S2 (always_on flag + Scribe-first prompt,658348f), S3 (project- scoped rules,43a860c), and now S4. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -56,6 +56,13 @@ creating a rule, call list_always_on_rules and list_rules(project_id=...) to
|
||||
avoid duplicates. Coordinate with the operator on whether a new rule belongs
|
||||
in a project, an existing rulebook+topic, or a new rulebook.
|
||||
|
||||
When you are working on a specific project, call enter_project(project_id)
|
||||
ONCE at session start (or whenever the active project changes). It returns the
|
||||
project, its applicable_rules + project_rules + subscribed_rulebooks, milestone
|
||||
summary, open tasks, and recent notes — everything you need to know the lay of
|
||||
the land before mutating. Don't call get_project + get_applicable_rules + a
|
||||
search separately when enter_project already composes them.
|
||||
|
||||
Plans are tasks with kind=plan, and Scribe is the canonical home for them.
|
||||
When you begin non-trivial work, call start_planning(project_id, title) FIRST —
|
||||
before any brainstorming, design, or plan-writing skill runs. start_planning
|
||||
|
||||
@@ -18,6 +18,7 @@ from __future__ import annotations
|
||||
|
||||
from fabledassistant.mcp._context import current_user_id
|
||||
from fabledassistant.services import milestones as milestones_svc
|
||||
from fabledassistant.services import notes as notes_svc
|
||||
from fabledassistant.services import projects as projects_svc
|
||||
from fabledassistant.services import rulebooks as rulebooks_svc
|
||||
from fabledassistant.services import trash as trash_svc
|
||||
@@ -34,6 +35,71 @@ async def list_projects() -> dict:
|
||||
return {"projects": [p.to_dict() for p in rows]}
|
||||
|
||||
|
||||
async def enter_project(project_id: int) -> dict:
|
||||
"""Session-start handshake: load full context for working on a project.
|
||||
|
||||
Call this FIRST whenever you're about to do project-scoped work
|
||||
(start_planning, create_task, update_*, anything that takes a project_id).
|
||||
One round-trip returns the project, its applicable rules (both rulebook-
|
||||
subscribed and project-scoped), milestone progress, open tasks, and
|
||||
recently-updated notes — everything you need to know the lay of the land
|
||||
before mutating.
|
||||
|
||||
No persistent server state: this is a read snapshot. Re-call if the
|
||||
session goes idle long enough that the data feels stale.
|
||||
|
||||
Args:
|
||||
project_id: The project to enter.
|
||||
|
||||
Returns a dict with keys: project, milestone_summary, applicable_rules,
|
||||
project_rules, subscribed_rulebooks, applicable_rules_truncated,
|
||||
open_tasks, recent_notes.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
project = await projects_svc.get_project(uid, project_id)
|
||||
if project is None:
|
||||
raise ValueError(f"project {project_id} not found")
|
||||
|
||||
applicable = await rulebooks_svc.get_applicable_rules(
|
||||
project_id=project_id, user_id=uid,
|
||||
)
|
||||
milestone_summary = await milestones_svc.get_project_milestone_summary(
|
||||
uid, project_id,
|
||||
)
|
||||
open_tasks, _ = await notes_svc.list_notes(
|
||||
uid, is_task=True, project_id=project_id,
|
||||
status=["todo", "in_progress"], sort="updated_at", limit=10,
|
||||
)
|
||||
recent_notes, _ = await notes_svc.list_notes(
|
||||
uid, is_task=False, project_id=project_id,
|
||||
sort="updated_at", limit=5,
|
||||
)
|
||||
|
||||
return {
|
||||
"project": project.to_dict(),
|
||||
"milestone_summary": milestone_summary,
|
||||
"applicable_rules": applicable["rules"],
|
||||
"project_rules": applicable.get("project_rules", []),
|
||||
"subscribed_rulebooks": applicable["subscribed_rulebooks"],
|
||||
"applicable_rules_truncated": applicable["truncated"],
|
||||
"open_tasks": [
|
||||
{
|
||||
"id": t.id, "title": t.title, "status": t.status,
|
||||
"priority": t.priority, "task_kind": t.task_kind,
|
||||
"milestone_id": t.milestone_id,
|
||||
}
|
||||
for t in open_tasks
|
||||
],
|
||||
"recent_notes": [
|
||||
{
|
||||
"id": n.id, "title": n.title,
|
||||
"updated_at": n.updated_at.isoformat() if n.updated_at else None,
|
||||
}
|
||||
for n in recent_notes
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def get_project(project_id: int) -> dict:
|
||||
"""Fetch a Scribe project by ID.
|
||||
|
||||
@@ -137,6 +203,7 @@ async def delete_project(project_id: int) -> dict:
|
||||
def register(mcp) -> None:
|
||||
for fn in (
|
||||
list_projects,
|
||||
enter_project,
|
||||
get_project,
|
||||
create_project,
|
||||
update_project,
|
||||
|
||||
@@ -6,7 +6,7 @@ import pytest
|
||||
from fabledassistant.mcp._context import _user_id_ctx
|
||||
from fabledassistant.mcp.tools.projects import (
|
||||
list_projects, get_project, create_project,
|
||||
update_project,
|
||||
update_project, enter_project,
|
||||
)
|
||||
|
||||
|
||||
@@ -130,3 +130,75 @@ async def test_update_project_raises_when_not_found():
|
||||
):
|
||||
with pytest.raises(ValueError, match="project 999 not found"):
|
||||
await update_project(project_id=999, title="x")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enter_project_composes_full_context():
|
||||
"""enter_project pulls project + rules + milestone summary + open tasks +
|
||||
recent notes in one composed call."""
|
||||
p = _fake_project(id=5, title="P")
|
||||
applicable_payload = {
|
||||
"rules": [{"id": 1, "title": "r1", "statement": "s",
|
||||
"topic_title": "t", "rulebook_title": "rb"}],
|
||||
"project_rules": [{"id": 99, "title": "pr1", "statement": "ps"}],
|
||||
"truncated": False,
|
||||
"subscribed_rulebooks": [{"id": 2, "title": "rb"}],
|
||||
}
|
||||
milestone_summary = [{"id": 10, "title": "MS", "task_count": 3}]
|
||||
|
||||
task1 = MagicMock()
|
||||
task1.id = 100; task1.title = "T1"; task1.status = "in_progress"
|
||||
task1.priority = "high"; task1.task_kind = "work"; task1.milestone_id = 10
|
||||
note1 = MagicMock()
|
||||
note1.id = 200; note1.title = "N1"
|
||||
note1.updated_at = None # avoids datetime mocking
|
||||
|
||||
with patch(
|
||||
"fabledassistant.mcp.tools.projects.projects_svc.get_project",
|
||||
AsyncMock(return_value=p),
|
||||
), patch(
|
||||
"fabledassistant.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
|
||||
AsyncMock(return_value=applicable_payload),
|
||||
), patch(
|
||||
"fabledassistant.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
|
||||
AsyncMock(return_value=milestone_summary),
|
||||
), patch(
|
||||
"fabledassistant.mcp.tools.projects.notes_svc.list_notes",
|
||||
AsyncMock(side_effect=[([task1], 1), ([note1], 1)]),
|
||||
):
|
||||
out = await enter_project(project_id=5)
|
||||
|
||||
assert out["project"]["id"] == 5
|
||||
assert out["milestone_summary"] == milestone_summary
|
||||
assert out["applicable_rules"][0]["title"] == "r1"
|
||||
assert out["project_rules"][0]["id"] == 99
|
||||
assert out["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}]
|
||||
assert out["open_tasks"][0]["id"] == 100
|
||||
assert out["open_tasks"][0]["status"] == "in_progress"
|
||||
assert out["recent_notes"][0]["id"] == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enter_project_raises_when_project_not_found():
|
||||
with patch(
|
||||
"fabledassistant.mcp.tools.projects.projects_svc.get_project",
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
with pytest.raises(ValueError, match="project 999 not found"):
|
||||
await enter_project(project_id=999)
|
||||
|
||||
|
||||
def test_enter_project_registered_in_register():
|
||||
"""register(mcp) registers enter_project alongside the existing tools."""
|
||||
from fabledassistant.mcp.tools.projects import register
|
||||
registered: list[str] = []
|
||||
|
||||
class FakeMCP:
|
||||
def tool(self, name=None):
|
||||
def decorator(fn):
|
||||
registered.append(name)
|
||||
return fn
|
||||
return decorator
|
||||
|
||||
register(FakeMCP())
|
||||
assert "enter_project" in registered
|
||||
|
||||
Reference in New Issue
Block a user