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
|
avoid duplicates. Coordinate with the operator on whether a new rule belongs
|
||||||
in a project, an existing rulebook+topic, or a new rulebook.
|
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.
|
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 —
|
When you begin non-trivial work, call start_planning(project_id, title) FIRST —
|
||||||
before any brainstorming, design, or plan-writing skill runs. start_planning
|
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.mcp._context import current_user_id
|
||||||
from fabledassistant.services import milestones as milestones_svc
|
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 projects as projects_svc
|
||||||
from fabledassistant.services import rulebooks as rulebooks_svc
|
from fabledassistant.services import rulebooks as rulebooks_svc
|
||||||
from fabledassistant.services import trash as trash_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]}
|
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:
|
async def get_project(project_id: int) -> dict:
|
||||||
"""Fetch a Scribe project by ID.
|
"""Fetch a Scribe project by ID.
|
||||||
|
|
||||||
@@ -137,6 +203,7 @@ async def delete_project(project_id: int) -> dict:
|
|||||||
def register(mcp) -> None:
|
def register(mcp) -> None:
|
||||||
for fn in (
|
for fn in (
|
||||||
list_projects,
|
list_projects,
|
||||||
|
enter_project,
|
||||||
get_project,
|
get_project,
|
||||||
create_project,
|
create_project,
|
||||||
update_project,
|
update_project,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import pytest
|
|||||||
from fabledassistant.mcp._context import _user_id_ctx
|
from fabledassistant.mcp._context import _user_id_ctx
|
||||||
from fabledassistant.mcp.tools.projects import (
|
from fabledassistant.mcp.tools.projects import (
|
||||||
list_projects, get_project, create_project,
|
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"):
|
with pytest.raises(ValueError, match="project 999 not found"):
|
||||||
await update_project(project_id=999, title="x")
|
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