"""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]} 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, design_system, systems, pattern_coverage — plus systems_bootstrap, present only when it applies (see below). `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. `inception` (milestone 297) appears ONLY when the project is yours and nobody has decided what it inherits: it carries the current defaults (which always-on rulebooks bind, 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 carries the chain-merged guidance (the house style AND this project's departures from it) plus a summary of the token set — treat it as binding for any UI you write, and pull the 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_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, ) # 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). 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 its defaults silently — always-on rulebooks, 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) # Probably the largest surfacing by volume, and it emitted nothing — so # the pulls it caused floated unattributed and the surfaced:pulled ratio # ran against a denominator missing its biggest contributor (#2477). An # AMBIENT source: these are top-N-by-recency, not a ranked choice, and the # readout counts them 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] + [int(n.id) for n in recent_notes], source="enter_project", ) # A project need not have one, and most installs won't — null is ordinary # here, not a missing prerequisite. design_system = None if project.design_system_id: design_system = await design_systems_svc.design_context( uid, project.design_system_id, ) # 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": project.to_dict(), "pattern_coverage": coverage_svc.coverage_line(coverage) if coverage else None, # Trimmed to what tagging needs. The full charter is get_system's job — # this list rides along on every session start, so it stays lean. "systems": [ { "id": s.id, "name": s.name, "description": (s.description or "").split("\n")[0][:200], } for s in systems ], "design_system": design_system, "milestone_summary": milestone_summary, **rulebooks_svc.rules_payload(applicable, user_id=uid, source="enter_project"), "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 ], } # 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 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, and the rulebook-applicable_rules / subscribed_rulebooks pair the assistant should consult when working on this project. """ 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() data["milestone_summary"] = await milestones_svc.get_project_milestone_summary( uid, project_id, ) 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( exclude_always_on_rulebooks, subscribe_rulebooks, 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 (exclude_always_on_rulebooks is None and subscribe_rulebooks is None and not design_system_id and seed_systems is None): return None return { "exclude_always_on_rulebooks": list(exclude_always_on_rulebooks or []), "subscribe_rulebooks": list(subscribe_rulebooks or []), "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 = "", exclude_always_on_rulebooks: list[int] | None = None, subscribe_rulebooks: list[int] | None = None, 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 four inception questions and pass the answers; a project created without any of them is UNDECIDED and enter_project will ask until decide_project_inception records it. Defaults if nobody decides: every always-on rulebook binds, nothing is subscribed, no design system, no Systems. 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"). exclude_always_on_rulebooks: always-on rulebook ids this project does NOT inherit ([] = inherit them all). list_rulebooks shows which are always_on. subscribe_rulebooks: rulebook ids to subscribe (the non-always-on ones). 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( exclude_always_on_rulebooks, subscribe_rulebooks, 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, exclude_always_on_rulebooks: list[int] | None = None, subscribe_rulebooks: list[int] | None = None, 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 — exclude_always_on_rulebook, subscribe_project_to_rulebook, set_project_design_system, the standard Systems seed — and writes the decision on the project last, so get_project/enter_project can say why the project has the rules, design and Systems it has. Re-deciding is additive for exclusions/subscriptions (use include_always_on_rulebook / unsubscribe_project_from_rulebook to undo one), replaces the design system, and never re-seeds Systems a project already has. Args: as create_project's inception args. Passing nothing records an inherit-all decision (every always-on rulebook binds, no subscriptions, no design system, no seed) — a valid answer, stated. """ uid = current_user_id() choices = _inception_choices( exclude_always_on_rulebooks, subscribe_rulebooks, 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)