"""System CRUD + record-association MCP tools — wrappers over services/systems.py. A System is a per-project, reusable, self-describing subsystem/area that any record (note, task, or issue) can be associated with — so research, build-work, and corrective work line up under the same area and recurring problem-spots are visible. The service enforces the multi-user ACL (project permission); these tools are thin wrappers. Sentinels (match the milestone/task tool conventions): - name="" / description="" / color="" / status="" → "leave unchanged" on update - order_index=-1 → "leave unchanged" on update (0 is a valid order_index) """ from __future__ import annotations from scribe.mcp._context import current_user_id from scribe.services import notes as notes_svc from scribe.services import systems as systems_svc # Below this, a project is young enough that the mild "which area is this # about?" question stays proportionate; at or above it, a zero-Systems project # has demonstrated that the question never converts (#2683 — Minstrel reached # 282 records without a single System) and the ask escalates. _BOOTSTRAP_MIN_RECORDS = 20 _BOOTSTRAP_TITLES = 6 # The standard vocabulary (#2798): area names that recur across software # projects, offered so "CI & Release" means the same thing in every project # on the instance. Consistency comes from the shared names — NOT from asking # the operator to approve each System; agents mint directly. Generic by # design (rule #115): archetypes any codebase could have, never one # install's subsystems. Mint freely beyond the list; the duplicate gate # guards sprawl. # The standard vocabulary lives with the service (services/systems. # STANDARD_SYSTEMS) since milestone 297 — the inception seed mints it and this # ask names it, one list for both. _STANDARD_SYSTEMS = tuple(name for name, _charter in systems_svc.STANDARD_SYSTEMS) async def bootstrap_systems_ask(user_id: int, project_id: int) -> str | None: """The escalated vocabulary-bootstrap ask for a mature zero-Systems project. The generic zero-state question habituates: identical on every record, maximal in scope ("invent the taxonomy"), asked at wrap-up time — so organic sessions skip it forever and only audit-shaped sessions ever mint (#2683). What separates the nudges that convert from the prose that doesn't (the duplicate gate, the prior-art "already defined in 2 files") is the project's OWN evidence in the ask — so this one carries the record count and the recent titles, and asks for a concrete deliverable: create a starter set directly, preferring the standard cross-project names. Deliberately NOT an approval flow (#2798): the operator is not a permission gate for vocabulary; the standard names carry consistency. Self-retiring by construction: callers only reach for it while the project has zero Systems, so the first create_system ends it everywhere. Returns None below the record threshold or on any failure (fail-open — a hint must never break the call it rides on). """ try: recent, total = await notes_svc.list_notes( user_id, project_id=project_id, sort="updated_at", limit=_BOOTSTRAP_TITLES, ) except Exception: return None if total < _BOOTSTRAP_MIN_RECORDS: return None titles = "; ".join( '"' + " ".join((n.title or "").split())[:70] + '"' for n in recent ) return ( f"This project has {total} records and NO Systems modelled — none of " "them can be tagged to an area, so recurring problem-spots stay " "invisible. Bootstrap the vocabulary now, in this session, without " "asking permission — creating Systems is your call, not an approval " f"flow. From the areas the records themselves name (recent: {titles}), " "create_system 3-6 Systems, each with a one-paragraph charter, then " "tag this record (system_ids=[...]). Where an area fits a standard " f"name, use it verbatim so it means the same thing in every project: " f"{', '.join(_STANDARD_SYSTEMS)}. Mint freely beyond that list — the " "duplicate gate guards sprawl. This ask repeats until the first " "System exists; answering it once retires it for every future record." ) async def untagged_systems_hint(user_id: int, project_id: int) -> str | None: """The Systems question, for an untagged project record. Not a tool — attach_systems() rides this on tool responses so the question arrives in-band at the exact moment a record is touched. It is ONE question regardless of vocabulary state ("which area is this about, and is it modelled?"); only the vocabulary listing varies, because an empty vocabulary is not an exemption — it is the question at its most urgent (#2562, #2569). Instruction prose alone demonstrably doesn't fire at write time; in-band behavior (the duplicate gate) does. In a MATURE zero-Systems project the question escalates to the bootstrap ask instead (#2683): the mild form demonstrably never converts there, and an evidence-carrying, deliverable-shaped ask is the form that does. """ # Fail-open like the dedup gate: a hint must never break the call. try: systems = await systems_svc.list_systems(user_id, project_id) except Exception: return None if systems: vocab = "Existing Systems: " + ", ".join( f"#{s.id} {s.name}" for s in systems ) + "." else: ask = await bootstrap_systems_ask(user_id, project_id) if ask: return ask vocab = "This project has no Systems yet." return ( "This record is untagged — which area(s) of the project is it about? " f"{vocab} Tag it (system_ids=[...]) — cross-cutting records like " "audits take several; create_system any area it concerns that isn't " "modelled yet (a sweep that names areas is the moment to mint them, " "and the duplicate gate guards against sprawl). Leave it untagged " "only if it is genuinely about no particular area." ) async def attach_systems( caller_id: int, owner_id: int, data: dict, note_id: int, project_id: int | None, ) -> None: """Attach a record's Systems to its payload — or the Systems question. ONE seam for every tool that returns a project record, read or write: a tagged record shows its areas (the touching-a-System reflex needs the affiliation visible on read, not just settable on write), an untagged project record carries the question instead. Neither field is ever attached empty (same reasoning as supersession_svc.attach_relations / #2483 — a field that always says nothing trains readers to skip fields). The hint goes only to the record's owner: tagging someone else's record in someone else's project is not the caller's call to make. Fail-open — decoration must never break the call it rides on. """ try: systems = await systems_svc.list_record_systems(owner_id, note_id) if systems: data["systems"] = [s.to_dict() for s in systems] elif project_id and caller_id == owner_id: hint = await untagged_systems_hint(owner_id, project_id) if hint: data["systems_hint"] = hint except Exception: pass async def create_system( project_id: int, name: str, description: str = "", color: str = "", ) -> dict: """Create a System (a reusable, self-describing subsystem/area) in a project. Associate records with it via the `system_ids` arg on create/update_task and create/update_note. Create one the moment two records would share an area that has no System yet — the same two-or-more test snippets use. Don't wait to be asked to name an area that plainly exists in the code, and don't route the creation through operator approval — minting vocabulary is the agent's call (#2798); an unmodelled area means every record about it stays untaggable. An audit or sweep that walks the codebase is a DISCOVERY moment: mint the Systems it names as it names them — the duplicate gate below, plus reviewing the existing list, is what guards against sprawl, not holding back. Prefer the standard cross-project names where the area fits one (CI & Release, Auth & Access, Data Model & Storage, API Surface, UI & Design, Import & Export, Background Jobs, Observability) so the same word means the same thing in every project. Give each one a one-paragraph charter, not just a label: the description is what tells a later session whether a record belongs here. Args: project_id: The project this system belongs to (required). name: Short label (required). description: What the system is and how it's used — a name is rarely enough. color: Optional UI accent (hex), or empty. Duplicate-gated like the other creates: if a System with the same normalized name already exists in this project (archived included), the call returns {"duplicate": true, "existing_id": ...} instead of creating — tag records to that one, or update_system it if its charter needs work. """ uid = current_user_id() norm = " ".join(name.split()).lower() if norm: try: existing = await systems_svc.list_systems( uid, project_id, include_archived=True ) except Exception: existing = [] for s in existing: if " ".join(s.name.split()).lower() == norm: return { "duplicate": True, "existing_id": s.id, "message": ( f"System '{s.name}' (#{s.id}) already covers this area " "in this project. Tag records to it with system_ids, " "or update_system it if the charter needs revising — " "a second System with the same name would split the " "area's records across two piles." ), } system = await systems_svc.create_system( uid, project_id=project_id, name=name, description=description or None, color=color or None, ) if system is None: raise ValueError(f"cannot create system in project {project_id} (no write access)") return system.to_dict() async def list_systems(project_id: int, include_archived: bool = False) -> dict: """List a project's systems (active by default), ordered by order_index. Pass include_archived=True to include archived systems. """ uid = current_user_id() rows = await systems_svc.list_systems(uid, project_id, include_archived=include_archived) return {"systems": [s.to_dict() for s in rows]} async def get_system(system_id: int) -> dict: """Fetch a System plus the records associated with it. Returns the system, plus its associated records split into `issues`, `tasks` (work/plan), and `notes`. """ uid = current_user_id() system = await systems_svc.get_system(uid, system_id) if system is None: raise ValueError(f"system {system_id} not found") records = await systems_svc.list_records_for_system(uid, system_id) issues, tasks, notes = [], [], [] for r in records: d = r.to_dict() if r.status is None: notes.append(d) elif r.task_kind == "issue": issues.append(d) else: tasks.append(d) data = system.to_dict() data["issues"] = issues data["tasks"] = tasks data["notes"] = notes return data async def update_system( system_id: int, name: str = "", description: str = "", color: str = "", status: str = "", order_index: int = -1, ) -> dict: """Update a System. Only explicitly provided fields change. Args: status: 'active' or 'archived'. Archive a system to retire it without losing history; archived systems hide from default lists. order_index: display position (0-based); -1 = leave unchanged. """ uid = current_user_id() fields: dict = {} if name: fields["name"] = name if description: fields["description"] = description if color: fields["color"] = color if status: fields["status"] = status if order_index >= 0: fields["order_index"] = order_index system = await systems_svc.update_system(uid, system_id, **fields) if system is None: raise ValueError(f"system {system_id} not found or no write access") return system.to_dict() async def list_system_records( system_id: int, kind: str = "", open_only: bool = False ) -> dict: """Everything filed under one System — the way to READ a subsystem. Reach for this when investigating a specific area: it returns the notes, tasks, issues and snippets someone deliberately tagged to it — the subsystem's accumulated record, unranked. Start with its reference note if one exists (titled "«System» — reference"); that is the living state, and the rest is history and open work around it. For a ranked cut of the same slice, search(system_id=...) filters semantic search to this association. Args: kind: filter by task_kind — 'issue', 'work', or 'plan'. Omit for all. open_only: limit to tasks not done/cancelled (e.g. open issues only). """ uid = current_user_id() rows = await systems_svc.list_records_for_system( uid, system_id, kind=kind or None, open_only=open_only, ) return {"records": [r.to_dict() for r in rows]} async def delete_system(system_id: int) -> dict: """Soft-delete a System (recoverable). Its record associations are removed.""" uid = current_user_id() ok = await systems_svc.delete_system(uid, system_id) if not ok: raise ValueError(f"system {system_id} not found or no write access") return {"message": f"System {system_id} deleted."} def register(mcp) -> None: for fn in ( create_system, list_systems, get_system, update_system, list_system_records, delete_system, ): mcp.tool(name=fn.__name__)(fn)