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,
|
||||
|
||||
Reference in New Issue
Block a user