"""Milestone CRUD MCP tools — thin wrappers over services/milestones.py. Mirrors existing fable-mcp milestone tool contracts: list/create/update. The existing surface has no fable_get_milestone or fable_delete_milestone — kept that way for parity. Sentinels: - title="" / description="" / status="" → "leave unchanged" on update - order_index=-1 → "leave unchanged" on update (0 is a valid order_index) - status="active" default on create """ from __future__ import annotations from scribe.mcp._context import current_user_id from scribe.services import milestones as milestones_svc from scribe.services import notes as notes_svc from scribe.services import rulebooks as rulebooks_svc from scribe.services import trash as trash_svc async def list_milestones(project_id: int) -> dict: """List milestones for a Scribe project, ordered by order_index. Returns id, title, description, body (the plan/design), status (active/done), order_index, and task counts. """ uid = current_user_id() rows = await milestones_svc.get_project_milestone_summary(uid, project_id) return {"milestones": rows} async def get_milestone(milestone_id: int) -> dict: """Fetch a milestone (the plan container) with its step-tasks and rules. A milestone IS a plan: its `body` holds the design/intent, and its steps are the child tasks listed here. Use this to read a plan top-to-bottom — the body for the design, `steps` for the trackable units of work. Mirrors the planning context that start_planning returns (applicable rules), so the rules surface again on recall. Returns: milestone (incl. body), progress, steps (its tasks ordered by status then update), and applicable_rules / subscribed_rulebooks. """ uid = current_user_id() milestone = await milestones_svc.get_milestone(uid, milestone_id) if milestone is None: raise ValueError(f"milestone {milestone_id} not found") progress = await milestones_svc.get_milestone_progress(milestone_id) steps, _ = await notes_svc.list_notes( uid, is_task=True, milestone_id=milestone_id, sort="status", limit=200, ) applicable = await rulebooks_svc.get_applicable_rules( project_id=milestone.project_id, user_id=uid, ) out = milestone.to_dict() out.update(progress) return { "milestone": out, "steps": [t.to_dict() for t in steps], "applicable_rules": applicable["rules"], "subscribed_rulebooks": applicable["subscribed_rulebooks"], "applicable_rules_truncated": applicable["truncated"], } async def create_milestone( project_id: int, title: str, description: str = "", body: str = "", status: str = "active", ) -> dict: """Create a milestone within a Scribe project. A milestone can serve as a plan container — put the design/intent in `body` and track each step as a child task (create_task(milestone_id=...)). For a fresh plan, prefer start_planning, which seeds the body template + surfaces the project's rules. Args: project_id: The project this milestone belongs to (required). title: Milestone name (required). description: Optional one-line summary of what this milestone covers. body: Optional plan/design (markdown) — the milestone's full plan text. status: active (default) or done. """ uid = current_user_id() milestone = await milestones_svc.create_milestone( uid, project_id=project_id, title=title, description=description or None, body=body or None, status=status, ) return milestone.to_dict() async def update_milestone( project_id: int, milestone_id: int, title: str = "", description: str = "", body: str = "", status: str = "", order_index: int = -1, ) -> dict: """Update a Scribe milestone. Only explicitly provided fields are changed. Args: project_id: Project the milestone belongs to (preserved for API parity; ownership scoping is enforced by user_id at the service layer). milestone_id: ID of the milestone to update. title: New title, or omit to leave unchanged. description: New one-line summary, or omit to leave unchanged. body: New plan/design (markdown), or omit to leave unchanged. status: New status — active or done. order_index: New display position (0-based). Use -1 to leave unchanged. """ uid = current_user_id() fields: dict = {} if title: fields["title"] = title if description: fields["description"] = description if body: fields["body"] = body if status: fields["status"] = status if order_index >= 0: fields["order_index"] = order_index milestone = await milestones_svc.update_milestone(uid, milestone_id, **fields) if milestone is None: raise ValueError(f"milestone {milestone_id} not found") return milestone.to_dict() async def delete_milestone(milestone_id: int) -> dict: """Move a milestone to the trash (recoverable). Its tasks go with it as one batch. Restore via restore(batch_id).""" uid = current_user_id() batch = await trash_svc.delete(uid, "milestone", milestone_id) if batch is None: raise ValueError(f"milestone {milestone_id} not found") return {"deleted_batch_id": batch, "message": f"Milestone {milestone_id} + its tasks moved to trash. Restore with restore('{batch}')."} def register(mcp) -> None: for fn in ( list_milestones, get_milestone, create_milestone, update_milestone, delete_milestone, ): mcp.tool(name=fn.__name__)(fn)