"""Project CRUD MCP tools — thin wrappers over services/projects.py. Mirrors existing fable-mcp project tool contracts. Note: there is no fable_delete_project here (matches existing fable-mcp surface). To stop working on a project, update its status to 'archived'. The LLM-era similarity-check / 'confirmed' guard from services/tools/projects.py is intentionally NOT replicated here — Claude is the client, not a weak local model that needs that guardrail. services.projects.create_project creates directly with no similarity warning. The auto-summary regeneration that services.projects.update_project triggers async will be removed in Phase 7 (it's an LLM call). The wrapper makes no assumption either way; once the service-layer side effect is gone, this code keeps working. """ from __future__ import annotations from scribe.mcp._context import current_user_id from scribe.mcp.tools import systems as systems_tools from scribe.services import coverage as coverage_svc from scribe.services import design_systems as design_systems_svc from scribe.services import inception as inception_svc from scribe.services import milestones as milestones_svc from scribe.services import notes as notes_svc from scribe.services import projects as projects_svc from scribe.services import rulebooks as rulebooks_svc from scribe.services import systems as systems_svc from scribe.services import trash as trash_svc from scribe.services.background import spawn from scribe.services.note_usage import record_surfaced async def list_projects() -> dict: """List all Scribe projects for the current user. Returns id, title, description, goal, status (active/paused/completed/archived), color, and a short auto-generated summary for each project. """ uid = current_user_id() rows = await projects_svc.list_projects(uid) return {"projects": [p.to_dict() for p in rows]} # How many of each kind the handshake lists. Enough to say what was worked on # lately and what's open; the rest is a list_milestones / list_tasks call away. # The handshake once carried every milestone's plan and came to ~222k # characters, past what a client accepts as a tool result (#4045). _HANDSHAKE_MILESTONES = 5 _HANDSHAKE_OPEN_TASKS = 10 async def enter_project(project_id: int) -> dict: """Session-start handshake: a primer on the project before you work in it. 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 what the project is for, what was worked on lately, what's open, and the vocabulary to record against. It is kept small on purpose: each part names the call that has the rest. 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, open_tasks, systems, design_system, project_rules, pattern_coverage — plus milestone_summary_omitted, inception and systems_bootstrap, each present only when it applies (see below). `project` is id, title, status and the full goal. get_project has the whole record. `milestone_summary` is the 5 most recently touched milestones, any status, most recent first. Touched counts a step changing, not only the milestone itself. Each carries its description and progress but NOT its plan: get_milestone(id) reads a plan and its steps. `milestone_summary_omitted` says how many others exist; list_milestones lists them all. `open_tasks` is the 10 most recently touched todo / in-progress tasks, with or without a milestone. A work-log counts as touching its task. Each names its milestone. list_tasks has the rest. `project_rules` lists the project's own rules by id and title. Global rules (the ones in rulebooks) apply here too and are not listed. Any rule reaches you in full when your work matches it — a global one or one of this project's, never another project's; get_rule(id) reads one, and search(content_type="rule", project_id=...) asks whether one covers what you are about to do. `pattern_coverage` (usually null) is the shape-accounting line — how many of the bound repo's extracted shapes carry a classification against canon (note 2786) — e.g. "shape accounting: 3100/4573 shapes accounted for — 12 canonical · 2900 instance (estimate, computed 2026-08-19); 1473 unclassified, largest: internal/api". Unclassified IS the todo: as you touch code in those areas, classify the shapes you can (instances of recorded canon, deliberate variants, one-off exemptions) and record the canon that's missing with create_snippet. A null line on a forge-served project usually means the ledger is seeding in the background (entering triggers it); refresh_pattern_coverage computes it on the spot. `systems` is the project's vocabulary of named subsystems/areas. It is returned here so you can TAG as you write: when creating or meaningfully updating a record, ask which of these areas it is about and pass their ids as `system_ids`. If the area a record describes is missing from this list, create it with create_system rather than leaving the area unmodelled. Read a subsystem's accumulated records with list_system_records. Each is id and name; get_system has the charter. `inception` (milestone 297) appears ONLY when the project is yours and nobody has decided what it inherits: it carries the current defaults (design system, Systems), what to ask the operator — once — and the decide_project_inception call that answers it; it repeats on every enter until a decision is recorded. `systems_bootstrap` appears ONLY when the project has many records and no Systems at all — act on it before starting other work: create_system a starter vocabulary from the areas the project's records name, directly and without asking permission, preferring the standard names the ask lists. It stops appearing the moment the first System exists. `design_system` is null unless the project points at one. When present it is a summary (title, what it inherits, token count and groups) and `guidance_call`. The guidance binds any UI you write the way a rule does: before writing UI, read `resolved_guidance` from get_design_system (the house style AND this project's departures from it), and pull values with resolve_design_system or get_design_system_stylesheet before reaching for a literal. Entering a project also SCOPES the session: reference and offer work on this project only, and pass its id to search / list_* so results stay inside it. If something clearly belongs to a different project, say so and ask before switching — never silently operate cross-project. The active project does not stick on the server (each call is self-contained); carrying its id forward is on you. Don't wait to be told which project is in scope. When work clearly belongs to a project but none is entered, look for a match yourself (list_projects / search on the repo or subject), propose it, and enter it once the operator confirms; if nothing matches, offer to create one — confirming name and goal first, never guessing a project into existence. """ 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_rows = await milestones_svc.get_project_milestone_summary( uid, project_id, ) milestone_summary, omitted = milestones_svc.brief_milestone_summary( milestone_rows, limit=_HANDSHAKE_MILESTONES, ) milestone_titles = {m["id"]: m.get("title") for m in milestone_rows} open_tasks, _ = await notes_svc.list_notes( uid, is_task=True, project_id=project_id, status=["todo", "in_progress"], sort="touched", limit=_HANDSHAKE_OPEN_TASKS, ) # The tagging vocabulary. Surfaced HERE because an instruction to "tag # records to Systems" is only executable if the list is in front of the # agent when it writes — which it never was, and tagging stopped within # three days of the feature landing (#2546's audit). Untagged writes now # also ask with the vocabulary listed; this copy lets the first write tag. systems = await systems_svc.list_systems(uid, project_id) # The arrival-moment half of the bootstrap ask (#2683): session start is # when the agent has just read the project map and is not yet deep in a # task — the one moment minting a starter vocabulary is cheap. The # write-moment half rides untagged-record responses (attach_systems); # both retire the instant the first System exists. systems_bootstrap = None if not systems: systems_bootstrap = await systems_tools.bootstrap_systems_ask( uid, project_id ) # The inception ask (milestone 297): a project nobody has decided on # inherits nothing, silently — no design system, no Systems. Owner-only (deciding is the owner's), and only until a # decision is recorded; the key is ABSENT otherwise (#2483). inception_ask = None if project.user_id == uid and not inception_svc.is_decided(project): inception_ask = await inception_svc.inception_ask(uid, project_id) # An AMBIENT source (#2477): top-N-by-recency, not a ranked choice, and # the readout counts it apart so dead-weight detection isn't poisoned by # "recently updated in a project you opened". record_surfaced( user_id=uid, note_ids=[int(t.id) for t in open_tasks], source="enter_project", ) # A project need not have one, and most installs won't — null is ordinary # here, not a missing prerequisite. Summary only: the guidance is ~9k of # prose most sessions never use, so it's one call away (#4045). design_system = None if project.design_system_id: design = await design_systems_svc.design_context( uid, project.design_system_id, ) if design: design_system = { k: design[k] for k in ("id", "title", "inherits_from", "token_count", "token_groups") } design_system["guidance_call"] = ( f"get_design_system({design['id']}) → resolved_guidance" ) # Cache read ONLY — computing coverage moves a repo tarball and never # belongs in this request path. Null is the ordinary state (no forge, or # never computed); the line appears exactly when there is evidence. Read # on the OWNER's id: bindings and the cache live with the project owner. coverage = await coverage_svc.cached_coverage( project.user_id or uid, project_id ) # Arrival self-seed (#2802): a ledger that is absent or stale refreshes in # the BACKGROUND — entering is the moment the number is wanted, and the # UI button must not be the only path. This enter stays fast; the next # one carries the line. Forge-less projects exit the seed quietly. spawn( coverage_svc.refresh_if_stale( project.user_id or uid, project_id, cached=coverage ), site="enter_project.coverage_seed", ) out = { "project": { "id": project.id, "title": project.title, "status": project.status, "goal": project.goal, }, "pattern_coverage": coverage_svc.coverage_line(coverage) if coverage else None, "systems": [{"id": s.id, "name": s.name} for s in systems], "design_system": design_system, "milestone_summary": milestone_summary, **rulebooks_svc.rules_payload( applicable, user_id=uid, source="enter_project", brief=True, ), "open_tasks": [ { "id": t.id, "title": t.title, "status": t.status, "milestone_id": t.milestone_id, "milestone_title": milestone_titles.get(t.milestone_id), } for t in open_tasks ], } # Attached only when it applies — a key that usually says null trains # readers to skip it (#2483), and this one exists to be acted on. if omitted: out["milestone_summary_omitted"] = ( f"{omitted} other milestone(s) not listed. " f"list_milestones({project_id}) lists every milestone." ) if systems_bootstrap: out["systems_bootstrap"] = systems_bootstrap if inception_ask: out["inception"] = inception_ask return out async def get_project(project_id: int) -> dict: """Fetch a Scribe project by ID. Returns full project fields, a milestone_summary list (every milestone, with description and progress but no plan body; get_milestone reads a plan), the project's own rules (project_rules), and applicable_rules: the global rules tagged to an area this project works in. Every other global rule applies too and arrives by retrieval when the work matches it. """ 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") data = project.to_dict() rows = await milestones_svc.get_project_milestone_summary(uid, project_id) data["milestone_summary"], _ = milestones_svc.brief_milestone_summary(rows) applicable = await rulebooks_svc.get_applicable_rules( project_id=project_id, user_id=uid, ) data.update(rulebooks_svc.rules_payload(applicable, user_id=uid, source="get_project")) return data def _inception_choices(design_system_id, seed_systems) -> dict | None: """The tool args → an inception choices object, or None when no inception arg was given at all (a bare create stays undecided and enter_project asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that system.""" if not design_system_id and seed_systems is None: return None return { "design_system_id": None if design_system_id in (0, -1) else design_system_id, "seed_systems": bool(seed_systems), } async def create_project( title: str, description: str = "", goal: str = "", status: str = "active", color: str = "", design_system_id: int = 0, seed_systems: bool | None = None, ) -> dict: """Create a new project in Scribe — and decide what it inherits. A project's inheritance is a decision, not a default (milestone 297): before calling, ask the operator the two inception questions and pass the answers; a project created without either is UNDECIDED and enter_project will ask until decide_project_inception records it. Defaults if nobody decides: no design system, no Systems. Rules are not an inception question: global rules (in rulebooks) apply to every project, and a project's own rules are written with create_project_rule. Args: title: Project name (required). description: Short summary of what the project is. goal: The desired outcome or definition of done for the project. status: one of active (default), paused, completed, archived. color: Optional hex colour for the project card (e.g. "#6366f1"). design_system_id: the design system this project's UI is built from (list_design_systems); -1 = explicitly none; 0 = not stated. seed_systems: true mints the standard starter Systems (CI & Release, Auth & Access, …) so records can be tagged from day one. """ uid = current_user_id() project = await projects_svc.create_project( uid, title=title, description=description, goal=goal, status=status, color=color or None, ) data = project.to_dict() choices = _inception_choices(design_system_id, seed_systems) if choices is not None: decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp") data["inception"] = decided["inception"] data["inception_effects"] = decided["effects"] else: data["inception_hint"] = ( "Undecided: this project inherits its defaults until " "decide_project_inception records what it should inherit " "(enter_project will ask)." ) return data async def decide_project_inception( project_id: int, design_system_id: int = 0, seed_systems: bool | None = None, ) -> dict: """Record what a project inherits — answer enter_project's `inception` ask, or re-decide later (milestone 297). Owner-only. Applies the effects through the ordinary tools' paths — set_project_design_system and the standard Systems seed — and writes the decision on the project last, so get_project/enter_project can say why the project has the design and Systems it has. Re-deciding replaces the design system and never re-seeds Systems a project already has. Args: as create_project's inception args. Passing nothing records a decision to take nothing (no design system, no seed) — a valid answer, stated. """ uid = current_user_id() choices = _inception_choices(design_system_id, seed_systems) or {} decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp") return {"project_id": project_id, **decided} async def update_project( project_id: int, title: str = "", description: str = "", goal: str = "", status: str = "", color: str = "", ) -> dict: """Update an existing Scribe project. Only explicitly provided fields are changed. Args: project_id: ID of the project to update. title: New title, or omit to leave unchanged. description: New description, or omit to leave unchanged. goal: New goal/definition-of-done, or omit to leave unchanged. status: New status — one of active, paused, completed, archived. color: New hex colour, or omit to leave unchanged. """ uid = current_user_id() fields: dict = {} if title: fields["title"] = title if description: fields["description"] = description if goal: fields["goal"] = goal if status: fields["status"] = status if color: fields["color"] = color project = await projects_svc.update_project(uid, project_id, **fields) if project is None: raise ValueError(f"project {project_id} not found") return project.to_dict() async def delete_project(project_id: int) -> dict: """Move a project to the trash (recoverable). Its milestones, tasks, and notes go with it as one batch. Restore via restore(batch_id).""" uid = current_user_id() batch = await trash_svc.delete(uid, "project", project_id) if batch is None: raise ValueError(f"project {project_id} not found") return {"deleted_batch_id": batch, "message": f"Project {project_id} + its contents moved to trash. Restore with restore('{batch}')."} def register(mcp) -> None: for fn in ( list_projects, enter_project, get_project, create_project, update_project, delete_project, decide_project_inception, ): mcp.tool(name=fn.__name__)(fn)